mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 12:07:37 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c82d8bccd7 | ||
|
|
7c50519264 | ||
|
|
2aaa265cdc | ||
|
|
ff72659a47 | ||
|
|
14af33d53d | ||
|
|
a604b7f773 | ||
|
|
41d4a9c45c | ||
|
|
293d60a89f | ||
|
|
34a8938dc7 | ||
|
|
36ef6cc216 | ||
|
|
f02c30eb55 | ||
|
|
ca084b2430 | ||
|
|
3740ec311b | ||
|
|
35bd8ac442 | ||
|
|
1fc05ca590 | ||
|
|
bda798b167 | ||
|
|
3babae35c0 | ||
|
|
3ab5c1433c | ||
|
|
49437a25b1 | ||
|
|
49403a554f | ||
|
|
87d6f93409 | ||
|
|
97d4eaa2ba | ||
|
|
df010c9ae8 | ||
|
|
cda0e981d2 | ||
|
|
5e8d4790a3 | ||
|
|
d390419abe | ||
|
|
86d5b05d30 | ||
|
|
6573f923b8 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -178,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -439,7 +439,7 @@
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "44.4.3",
|
||||
"electron": "44.4.5",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
@@ -458,7 +458,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -495,7 +495,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -511,7 +511,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -530,7 +530,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -557,7 +557,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -572,7 +572,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -611,7 +611,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -641,7 +641,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -656,7 +656,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -680,7 +680,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -701,7 +701,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -723,7 +723,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -758,7 +758,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -778,7 +778,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -812,7 +812,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -831,7 +831,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -877,7 +877,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -891,7 +891,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -925,7 +925,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -960,7 +960,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -998,7 +998,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1039,7 +1039,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1119,7 +1119,7 @@
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
@@ -2530,11 +2530,11 @@
|
||||
|
||||
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
||||
|
||||
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
|
||||
"@pierre/diffs": ["@pierre/diffs@1.5.1", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-+EXNfz4ZXI6FHr7P6ToWEORwyYiPVfAmzhA70xF+0y/oNvj7gbNTLJJ6jNzkBjuMbRlu564TAmgYZLc5OyHURA=="],
|
||||
|
||||
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
||||
"@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="],
|
||||
|
||||
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
|
||||
"@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="],
|
||||
|
||||
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
|
||||
|
||||
@@ -3870,7 +3870,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
|
||||
"electron": ["electron@44.4.5", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
|
||||
|
||||
@@ -6258,11 +6258,7 @@
|
||||
|
||||
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"@pierre/diffs/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
"@pierre/diffs/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@pierre/trees/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
@@ -7160,8 +7156,6 @@
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@pierre/diffs/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -68,7 +68,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/event-listener": "2.4.6",
|
||||
"@solid-primitives/media": "2.3.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -39,6 +39,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared }
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
import { claudeVersion, supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
@@ -988,22 +989,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
id.toLowerCase(),
|
||||
)?.groups
|
||||
if (!match) return undefined
|
||||
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
|
||||
}
|
||||
|
||||
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
}
|
||||
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
@@ -1652,8 +1637,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
|
||||
betas.push("mid-conversation-output-config-2026-07-01")
|
||||
|
||||
const thinking = body.thinking
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
|
||||
betas.push("thinking-binding-controls-2026-08-01")
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
|
||||
return betas
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { supportsThinkingBlockBinding, THINKING_BINDING_BETA } from "./utils/claude-model.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
@@ -443,6 +444,23 @@ const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Opt
|
||||
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
|
||||
const MIN_THINKING_BUDGET = 1_024
|
||||
|
||||
const isThinkingDisabled = Schema.is(
|
||||
Schema.Struct({
|
||||
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
|
||||
}),
|
||||
)
|
||||
|
||||
// Claude 5.1+ binds each thinking signature to the prefix above it. Ask Bedrock to drop the affected blocks instead of
|
||||
// failing when that prefix changes. `http.body` overlays this field by field, so callers can still override it.
|
||||
const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Record<string, unknown>> | undefined) => {
|
||||
if (isThinkingDisabled(request.http?.body)) return thinking
|
||||
if (!supportsThinkingBlockBinding(request.model)) return thinking
|
||||
return {
|
||||
...(thinking ?? { type: "adaptive" as const }),
|
||||
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
||||
}
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
@@ -450,7 +468,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const maxTokens =
|
||||
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
|
||||
const thinking =
|
||||
const thinking = applyThinkingBindingDefault(
|
||||
request,
|
||||
options.thinking === undefined
|
||||
? undefined
|
||||
: {
|
||||
@@ -460,7 +479,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
maxTokens,
|
||||
MIN_THINKING_BUDGET,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
@@ -509,6 +529,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
: {
|
||||
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
|
||||
...(thinking === undefined ? {} : { thinking }),
|
||||
// Converse takes Anthropic betas in the body, and Bedrock rejects `block_binding` without this one.
|
||||
...(thinking?.block_binding === undefined ? {} : { anthropic_beta: [THINKING_BINDING_BETA] }),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -555,7 +577,7 @@ interface ParserState {
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
|
||||
readonly reasoningRedactedContent: Readonly<Record<number, Uint8Array[]>>
|
||||
}
|
||||
|
||||
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => Encoding.encodeBase64(concatBytes(chunks))
|
||||
@@ -605,10 +627,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const index = event.contentBlockDelta.contentBlockIndex
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedChunks = yield* (() => {
|
||||
const redactedChunk = yield* (() => {
|
||||
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
|
||||
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
|
||||
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
@@ -619,17 +640,21 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
),
|
||||
)
|
||||
})()
|
||||
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
|
||||
const redactedChunks = state.reasoningRedactedContent[index] ?? []
|
||||
if (redactedChunk !== undefined) redactedChunks.push(redactedChunk)
|
||||
const metadata = (() => {
|
||||
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
if (redactedChunk === undefined && reasoning.data !== undefined)
|
||||
return providerMetadata(state.providerMetadataKey, { redactedData: reasoning.data })
|
||||
})()
|
||||
const lifecycle = (() => {
|
||||
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
|
||||
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
if (reasoning.text !== undefined || metadata !== undefined)
|
||||
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
if (redactedChunk !== undefined) return Lifecycle.reasoningStart(state.lifecycle, events, `reasoning-${index}`)
|
||||
return state.lifecycle
|
||||
})()
|
||||
const reasoningRedactedContent = (() => {
|
||||
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
|
||||
if (redactedChunk !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
|
||||
if (reasoning.data === undefined) return state.reasoningRedactedContent
|
||||
return Object.fromEntries(
|
||||
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
|
||||
@@ -765,7 +790,19 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
return state.finishReason.normalized
|
||||
})()
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
const lifecycle = Object.entries(state.reasoningRedactedContent).reduce((current, [index, chunks]) => {
|
||||
const signature = state.reasoningSignatures[Number(index)]
|
||||
return Lifecycle.reasoningEnd(
|
||||
current,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
providerMetadata(
|
||||
state.providerMetadataKey,
|
||||
signature ? { signature } : { redactedData: encodeRedactedContent(chunks) },
|
||||
),
|
||||
)
|
||||
}, state.lifecycle)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
...state.finishReason,
|
||||
normalized,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01"
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
export const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
id.toLowerCase(),
|
||||
)?.groups
|
||||
if (!match) return undefined
|
||||
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
|
||||
}
|
||||
|
||||
export const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsThinkingBlockBinding
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import {
|
||||
AuthenticationError,
|
||||
ContentPolicyError,
|
||||
@@ -109,7 +109,8 @@ const QUOTA_CODES = new Set([
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -154,6 +155,41 @@ const CONTENT_POLICY_TEXT =
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
const Message = Schema.String.check(Schema.isPattern(/\S/))
|
||||
|
||||
const messageAt = <Fields extends Schema.Struct.Fields>(
|
||||
fields: Fields,
|
||||
message: (body: Schema.Struct<Fields>["Type"]) => string,
|
||||
) =>
|
||||
Schema.Struct(fields).pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform(message),
|
||||
encode: SchemaGetter.forbidden(() => "Provider error messages are decode-only"),
|
||||
}),
|
||||
)
|
||||
|
||||
// Common error body layouts that carry a human-readable message, in priority order.
|
||||
// Provider-specific layouts belong in their protocol.
|
||||
const decodeMessage = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Union([
|
||||
messageAt({ error: Schema.Struct({ message: Message }) }, (body) => body.error.message),
|
||||
messageAt({ error: Message }, (body) => body.error),
|
||||
messageAt({ message: Message }, (body) => body.message),
|
||||
// AWS services
|
||||
messageAt({ Message: Message }, (body) => body.Message),
|
||||
// RFC 9457 problem details
|
||||
messageAt({ detail: Message }, (body) => body.detail),
|
||||
messageAt(
|
||||
{ errors: Schema.NonEmptyArray(Schema.Struct({ message: Message })) },
|
||||
(body) => body.errors[0].message,
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
export const providerErrorMessage = (body: string) => Option.getOrUndefined(decodeMessage(body))
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
@@ -259,6 +295,10 @@ function providerCodes(value: unknown) {
|
||||
error?.type,
|
||||
error?.status,
|
||||
error?.error_type,
|
||||
// Google `google.rpc.ErrorInfo` details carry the specific reason.
|
||||
...(Array.isArray(error?.details)
|
||||
? error.details.map((detail) => (isRecord(detail) ? detail.reason : undefined))
|
||||
: []),
|
||||
inner?.code,
|
||||
metadata?.error_type,
|
||||
responseError?.code,
|
||||
|
||||
@@ -28,7 +28,8 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
const compatibleRoute = AnthropicMessages.route.with({ id: "anthropic-compatible-messages", provider: id })
|
||||
export const routes = [compatibleRoute]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
@@ -43,7 +44,7 @@ export const configure = (input: Config) => {
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
const route = (provider === "anthropic" ? AnthropicMessages.route : compatibleRoute).with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
|
||||
@@ -35,13 +35,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
id: "openai-responses",
|
||||
id: "xai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "openai-responses",
|
||||
id: "xai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
@@ -53,7 +53,7 @@ const responsesRoute = Route.make({
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "openai-compatible-chat",
|
||||
id: "xai-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js"
|
||||
import { Service, type HttpMiddleware, type Interface } from "./executor-service.js"
|
||||
|
||||
export { Service } from "./executor-service.js"
|
||||
@@ -84,21 +84,17 @@ export const responseHttp = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
headers: headerDetails(response.headers),
|
||||
})
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const MAX_BODY_CHARS = 2000
|
||||
|
||||
// Without a recognized message, show the raw body so the provider's explanation is never dropped.
|
||||
const providerMessage = (status: number, body: string | void) => {
|
||||
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
||||
return (
|
||||
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
||||
`Provider request failed with HTTP ${status}`
|
||||
)
|
||||
const fallback = `Provider request failed with HTTP ${status}`
|
||||
const text = body?.trim() ?? ""
|
||||
const message = providerErrorMessage(text)
|
||||
if (message) return message
|
||||
// Gateway and proxy HTML error pages are markup, not an explanation.
|
||||
if (!text || /^<(?:!doctype|html)/i.test(text)) return fallback
|
||||
return `${fallback}: ${text.length > MAX_BODY_CHARS ? `${text.slice(0, MAX_BODY_CHARS)}…` : text}`
|
||||
}
|
||||
|
||||
const statusError = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src/index.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
|
||||
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
|
||||
import * as Gemini from "../src/protocols/gemini.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
@@ -107,6 +107,26 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model(
|
||||
"compatible",
|
||||
),
|
||||
system: "You are concise.",
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("anthropic-compatible-messages")
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -273,10 +273,56 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400: invalid parameter")
|
||||
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("shows unrecognized provider error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe(
|
||||
'Provider request failed with HTTP 422: {"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}',
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse('{"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}', {
|
||||
status: 422,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("shows messages from common provider error layouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Invalid API Key")
|
||||
}).pipe(Effect.provide(fixedResponse('{"detail":"Invalid API Key"}', { status: 401 }))),
|
||||
)
|
||||
|
||||
it.effect("truncates long unrecognized provider error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe(`Provider request failed with HTTP 400: ${"x".repeat(2000)}…`)
|
||||
expect(error.reason.body).toHaveLength(5000)
|
||||
}).pipe(Effect.provide(fixedResponse("x".repeat(5000), { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("does not show HTML error pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Provider request failed with HTTP 502")
|
||||
expect(error.reason.body).toContain("Bad Gateway")
|
||||
}).pipe(Effect.provide(fixedResponse("<!DOCTYPE html><html><body>Bad Gateway</body></html>", { status: 502 }))),
|
||||
)
|
||||
|
||||
it.effect("preserves structured provider messages from large error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
@@ -299,7 +345,7 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("falls back when structured provider messages are empty", () =>
|
||||
it.effect("shows the body when structured provider messages are empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
@@ -308,7 +354,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
})
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
expect(error.message).toBe('Provider request failed with HTTP 400: {"error":{"message":" "}}')
|
||||
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,8 +154,8 @@ describe("public exports", () => {
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
|
||||
expect(OpenAI.provider.image).toBe(OpenAI.image)
|
||||
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse-thinking-binding",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "bedrock-converse-thinking-binding/accepts-the-default-binding-with-no-thinking-configured",
|
||||
"recordedAt": "2026-09-28T23:09:14.527Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/global.anthropic.claude-opus-5-5/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"global.anthropic.claude-opus-5-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":1024},\"additionalModelRequestFields\":{\"thinking\":{\"type\":\"adaptive\",\"block_binding\":{\"prefix_mismatch_behavior\":\"drop_block\"}},\"anthropic_beta\":[\"thinking-binding-controls-2026-08-01\"]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAoQAAAFKtAFmXCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInJvbGUiOiJhc3Npc3RhbnQifUMJiXoAAAC8AAAAV0Ua/isLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSGVsbG8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDRCJ91Ic6iwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9qr7BagAAAIYAAABRR+j7OQs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0pJ2GhAAABCAAAAE4PaiuaCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTI0OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MjgsIm91dHB1dFRva2VucyI6NSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjMzfX2s2SiQ",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+71
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isContextOverflow } from "../src/index.js"
|
||||
import { classifyProviderFailure } from "../src/provider-error.js"
|
||||
import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
@@ -355,6 +355,32 @@ describe("provider error rawBody classification", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "API key not valid. Please pass a valid API key.",
|
||||
status: "INVALID_ARGUMENT",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
reason: "API_KEY_INVALID",
|
||||
domain: "googleapis.com",
|
||||
},
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
|
||||
locale: "en-US",
|
||||
message: "API key not valid. Please pass a valid API key.",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
|
||||
._tag,
|
||||
).toBe("Authentication")
|
||||
})
|
||||
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
@@ -370,3 +396,42 @@ describe("provider error rawBody classification", () => {
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error messages", () => {
|
||||
test("reads messages from common error body layouts", () => {
|
||||
expect(
|
||||
[
|
||||
'{"error":{"message":"Invalid API Key","type":"invalid_request_error"}}',
|
||||
'{"code":"invalid-argument","error":"Incorrect API key provided."}',
|
||||
'{"message":"1 validation error detected"}',
|
||||
'{"Message":"Invalid API Key format: Must start with pre-defined prefix"}',
|
||||
'{"type":"about:blank","title":"Gone","status":410,"detail":"The model has reached its end of life"}',
|
||||
'{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}',
|
||||
].map(providerErrorMessage),
|
||||
).toEqual([
|
||||
"Invalid API Key",
|
||||
"Incorrect API key provided.",
|
||||
"1 validation error detected",
|
||||
"Invalid API Key format: Must start with pre-defined prefix",
|
||||
"The model has reached its end of life",
|
||||
"Authentication error",
|
||||
])
|
||||
})
|
||||
|
||||
test("prefers the nested error message over a top-level message", () => {
|
||||
expect(providerErrorMessage('{"message":"Bad Request","error":{"message":"model not found"}}')).toBe(
|
||||
"model not found",
|
||||
)
|
||||
})
|
||||
|
||||
test("ignores blank, non-string, and non-JSON messages", () => {
|
||||
expect(
|
||||
[
|
||||
'{"error":{"message":" "}}',
|
||||
'{"message":{"detail":[{"msg":"too high"}]}}',
|
||||
'{"errors":[]}',
|
||||
"invalid parameter",
|
||||
].map(providerErrorMessage),
|
||||
).toEqual([undefined, undefined, undefined, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -300,7 +300,9 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.id).toBe("anthropic-compatible-messages")
|
||||
expect(selected.route.protocol).toBe("anthropic-messages")
|
||||
expect(selected.route.providerMetadataKey).toBe("example")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
})
|
||||
@@ -319,6 +321,7 @@ describe("provider package entrypoints", () => {
|
||||
thinking: { type: "adaptive" },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
|
||||
|
||||
// Claude Opus 5.5 enforces the conversation-prefix check on Bedrock, so a changed system prompt is a real
|
||||
// mismatch. Override with BEDROCK_BINDING_MODEL_ID for another model that enforces it.
|
||||
const modelID = process.env.BEDROCK_BINDING_MODEL_ID ?? "global.anthropic.claude-opus-5-5"
|
||||
|
||||
// Mirrors the "high" variant, so the recording covers the real overlay and default merge.
|
||||
const model = (blockBinding?: { readonly prefix_mismatch_behavior: string }) =>
|
||||
AmazonBedrock.model(modelID, {
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: RECORDING_REGION,
|
||||
body: {
|
||||
additionalModelRequestFields: {
|
||||
thinking: { type: "adaptive", display: "summarized", ...(blockBinding ? { block_binding: blockBinding } : {}) },
|
||||
output_config: { effort: "high" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const question = "How many positive integers below 5000 have exactly 12 positive divisors? Reply with the number only."
|
||||
|
||||
const first = LLM.request({
|
||||
id: "recorded_bedrock_thinking_binding_first",
|
||||
model: model(),
|
||||
system: "You are a concise assistant.",
|
||||
prompt: question,
|
||||
cache: "none",
|
||||
generation: { maxTokens: 12_000 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-converse-thinking-binding",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
})
|
||||
|
||||
describe("Bedrock Converse thinking binding recorded", () => {
|
||||
recorded.effect.with("accepts the default binding with no thinking configured", { tags: ["reasoning"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_thinking_binding_default",
|
||||
model: AmazonBedrock.model(modelID, {
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: RECORDING_REGION,
|
||||
}),
|
||||
system: "Reply with the single word 'Hello'.",
|
||||
prompt: "Say hello.",
|
||||
cache: "none",
|
||||
generation: { maxTokens: 1_024 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
expect(response.text).toMatch(/hello/i)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"keeps a session working after the system prompt changes",
|
||||
{ tags: ["reasoning"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const turn = yield* LLMClient.generate(first)
|
||||
// The signed thinking block is what Bedrock binds to the first system prompt.
|
||||
expect(turn.message.content.some((part) => part.type === "reasoning")).toBe(true)
|
||||
|
||||
// Replay the assistant turn under a different system prompt, as a prompt rebuild or compaction would.
|
||||
const next = LLMRequest.update(first, {
|
||||
id: "recorded_bedrock_thinking_binding_next",
|
||||
system: [{ type: "text", text: "You are a concise assistant. Today is Monday." }],
|
||||
messages: [
|
||||
...first.messages,
|
||||
Message.assistant(turn.message.content),
|
||||
Message.user("Now double it. Reply with the number only."),
|
||||
],
|
||||
})
|
||||
|
||||
const bound = yield* LLMClient.generate(next)
|
||||
expect(bound.finishReason?.normalized).toBe("stop")
|
||||
expect(bound.text).toMatch(/\d/)
|
||||
|
||||
// The same replay is rejected when the caller asks for the default `error` behavior, proving the check is
|
||||
// enforced and that the default `drop_block` is what let the request above through.
|
||||
const rejected = yield* LLMClient.generate(
|
||||
LLMRequest.update(next, {
|
||||
id: "recorded_bedrock_thinking_binding_error",
|
||||
model: model({ prefix_mismatch_behavior: "error" }),
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(rejected.message).toContain("bound to a different conversation")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { GenerationOptions, LanguageModel, LLM } from "../../src/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const binding = { prefix_mismatch_behavior: "drop_block" }
|
||||
const beta = ["thinking-binding-controls-2026-08-01"]
|
||||
|
||||
const bedrock = (id: string, settings: Parameters<typeof AmazonBedrock.model>[1] = {}) =>
|
||||
AmazonBedrock.model(id, { baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", ...settings })
|
||||
|
||||
const fields = (model: LanguageModel, generation?: GenerationOptions) =>
|
||||
compileRequest(
|
||||
LLM.request({ id: "req_1", model, system: "You are concise.", prompt: "Say hello.", cache: "none", generation }),
|
||||
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
|
||||
|
||||
describe("Bedrock Converse thinking block binding", () => {
|
||||
for (const id of [
|
||||
"global.anthropic.claude-fable-5-1",
|
||||
"us.anthropic.claude-fable-5-1-v1:0",
|
||||
"anthropic.claude-fable-5.1",
|
||||
"anthropic.claude-mythos-5-1",
|
||||
"us.anthropic.claude-opus-5-5",
|
||||
"global.anthropic.claude-sonnet-5-5",
|
||||
"anthropic.claude-opus-6",
|
||||
]) {
|
||||
it.effect(`defaults adaptive thinking to drop_block with the beta for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fields(bedrock(id))).toEqual({
|
||||
thinking: { type: "adaptive", block_binding: binding },
|
||||
anthropic_beta: beta,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const id of [
|
||||
"global.anthropic.claude-opus-5",
|
||||
"us.anthropic.claude-sonnet-5",
|
||||
"global.anthropic.claude-fable-5",
|
||||
"anthropic.claude-fable-5-v1:0",
|
||||
"global.anthropic.claude-opus-4-8",
|
||||
"us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"us.amazon.nova-2-lite-v1:0",
|
||||
]) {
|
||||
it.effect(`leaves ${id} untouched`, () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fields(bedrock(id))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("binds a manual thinking budget and keeps top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* fields(
|
||||
bedrock("global.anthropic.claude-fable-5-1", { thinking: { type: "enabled", budgetTokens: 4_000 } }),
|
||||
GenerationOptions.make({ maxTokens: 64_000, topK: 40 }),
|
||||
),
|
||||
).toEqual({
|
||||
top_k: 40,
|
||||
thinking: { type: "enabled", budget_tokens: 4_000, block_binding: binding },
|
||||
anthropic_beta: beta,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not bind disabled thinking", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* fields(
|
||||
bedrock("global.anthropic.claude-fable-5-1", {
|
||||
body: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors the compatibility override in both directions", () =>
|
||||
Effect.gen(function* () {
|
||||
const arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"
|
||||
expect(yield* fields(bedrock(arn))).toBeUndefined()
|
||||
expect(
|
||||
yield* fields(LanguageModel.update(bedrock(arn), { compatibility: { supportsThinkingBlockBinding: true } })),
|
||||
).toEqual({ thinking: { type: "adaptive", block_binding: binding }, anthropic_beta: beta })
|
||||
expect(
|
||||
yield* fields(
|
||||
LanguageModel.update(bedrock("global.anthropic.claude-fable-5-1"), {
|
||||
compatibility: { supportsThinkingBlockBinding: false },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1301,11 +1301,11 @@ describe("Bedrock Converse route", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
|
||||
type: "reasoning-delta",
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
|
||||
expect(response.events.find((event) => event.type === "reasoning-start")).toEqual({
|
||||
type: "reasoning-start",
|
||||
id: "reasoning-0",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
@@ -1383,6 +1383,13 @@ describe("Bedrock Converse route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { bedrock: { redactedData: "AQID" } },
|
||||
text: undefined,
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
|
||||
])
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("xAI Responses route", () => {
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
expect(prepared.route).toBe("xai-responses")
|
||||
expect(prepared.protocol).toBe("xai-responses")
|
||||
expect(prepared.model.route.providerMetadataKey).toBe("xai")
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
@@ -298,3 +300,14 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }),
|
||||
)
|
||||
expect(prepared.route).toBe("xai-chat")
|
||||
expect(prepared.protocol).toBe("openai-chat")
|
||||
expect(prepared.model.route.providerMetadataKey).toBe("xai")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -415,6 +415,7 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
}}
|
||||
enableLineSelection
|
||||
enableGutterUtility
|
||||
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -309,6 +309,7 @@ async function capture(input: Parameters<typeof run>[0]) {
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
describe("runNonInteractivePrompt", () => {
|
||||
@@ -433,6 +434,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
expect(sdk.form.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree" },
|
||||
})
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test("attach mode cancels only session-owned forms", async () => {
|
||||
@@ -448,6 +450,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
{ sessionID: "global", formID: "frm_pending_global" },
|
||||
expect.anything(),
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,13 +2,12 @@ export * as InstructionBuiltIns from "./builtins.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { Instructions } from "./index.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
|
||||
@@ -19,16 +18,24 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
return Service.of({
|
||||
load: (sessionID) =>
|
||||
load: () =>
|
||||
Effect.succeed(
|
||||
Instructions.combine([
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
render: {
|
||||
initial: (date) => `Today's date: ${date}`,
|
||||
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/environment"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.sync(() =>
|
||||
[
|
||||
"<env>",
|
||||
` Current conversation session ID: ${sessionID}`,
|
||||
` Working directory: ${location.directory}`,
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
@@ -44,15 +51,6 @@ const layer = Layer.effect(
|
||||
["The environment you are running in is now:", environment].join("\n"),
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
render: {
|
||||
initial: (date) => `Today's date: ${date}`,
|
||||
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,55 +1,62 @@
|
||||
import { Effect } from "effect"
|
||||
import { isArrayNonEmpty } from "effect/Array"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
const accountIdField = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
})
|
||||
|
||||
const gatewayIdField = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "gatewayId",
|
||||
title: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
required: true,
|
||||
})
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare.ai.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
if (typeof configured?.baseURL === "string") return
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
|
||||
const gatewayId =
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ||
|
||||
stringOption(configured ?? {}, "gatewayId") ||
|
||||
stringOption(configured ?? {}, "gateway")
|
||||
if (accountId && gatewayId) return
|
||||
const accountIdForm = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
})
|
||||
const gatewayIdForm = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "gatewayId",
|
||||
title: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
required: true,
|
||||
})
|
||||
if (accountId) return Form.Fields.make([gatewayIdForm])
|
||||
if (gatewayId) return Form.Fields.make([accountIdForm])
|
||||
return Form.Fields.make([accountIdForm, gatewayIdForm])
|
||||
})
|
||||
const fields =
|
||||
typeof configured?.baseURL === "string"
|
||||
? []
|
||||
: [
|
||||
...(accountId || typeof configured?.accountId === "string" ? [] : [accountIdField]),
|
||||
...(gatewayId || typeof configured?.gatewayId === "string" ? [] : [gatewayIdField]),
|
||||
]
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: providerID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form,
|
||||
form: isArrayNonEmpty(fields) ? Form.Fields.make(fields) : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
const item = evt.get(providerID)
|
||||
if (!item || (!accountId && !gatewayId)) return
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
provider.settings = {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(gatewayId ? { gatewayId } : {}),
|
||||
...provider.settings,
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
})
|
||||
|
||||
function resolveAccountId(options: Record<string, unknown>) {
|
||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
return stringOption(options, "accountId") ?? process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
}
|
||||
|
||||
function workersEndpoint(accountId: string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Model } from "../../model.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
@@ -272,7 +273,7 @@ export const GithubCopilotPlugin = define({
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
evt.headers["X-Interaction-Id"] = evt.sessionID
|
||||
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
@@ -299,12 +300,16 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
(evt) =>
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
if (!chatgpt) return
|
||||
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
|
||||
evt.baseURL = codexBaseURL
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: evt.sessionID })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
// ChatGPT routes its prompt cache on this header, so children share the parent's.
|
||||
evt.headers["session-id"] = session ? SessionAffinity.get(session) : evt.sessionID
|
||||
}),
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * as SessionAffinity from "./affinity.js"
|
||||
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`?
|
||||
/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */
|
||||
export const get = (session: Pick<SessionSchema.Info, "id" | "parentID" | "fork">) =>
|
||||
session.parentID ?? session.fork?.sessionID ?? session.id
|
||||
@@ -132,7 +132,7 @@ const layer = Layer.effect(
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
builtins: builtins.load(),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(permissions),
|
||||
references: referenceInstructions.load(),
|
||||
@@ -144,13 +144,16 @@ const layer = Layer.effect(
|
||||
return {
|
||||
session,
|
||||
agent: { ...agent, info: agent.info },
|
||||
// Ordered from most to least shared across sessions so the baseline stays a reusable
|
||||
// prompt-cache prefix: user-level catalog and guidance, then project instructions, then
|
||||
// the date and environment, which vary by day and directory.
|
||||
instructions: Instructions.combine([
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
loaded.mcp,
|
||||
loaded.references,
|
||||
loaded.skills,
|
||||
loaded.discovery,
|
||||
loaded.builtins,
|
||||
loaded.entries,
|
||||
]),
|
||||
tools: loaded.tools,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionAffinity } from "./affinity.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
@@ -46,6 +47,8 @@ const IMAGE_REMOVED =
|
||||
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
|
||||
// Used when the catalog has no output limit for the model.
|
||||
const OUTPUT_TOKEN_FALLBACK = 32_000
|
||||
// No reply needs more, however much the model allows.
|
||||
const OUTPUT_TOKEN_MAX = 256_000
|
||||
// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates.
|
||||
const SUMMARY_OUTPUT_MAX = 32_000
|
||||
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
|
||||
@@ -87,7 +90,7 @@ const outputLimit = (
|
||||
kind: "primary" | "compaction",
|
||||
inputTokens?: Input["inputTokens"],
|
||||
) => {
|
||||
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
|
||||
const model = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX)
|
||||
const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model
|
||||
if (inputTokens === undefined || limit.context <= 0) return requested
|
||||
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
|
||||
@@ -268,7 +271,7 @@ export const layer = Layer.effect(
|
||||
const entries = Object.entries(shaped.options)
|
||||
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
|
||||
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
|
||||
const affinity = session.parentID ?? session.fork?.sessionID ?? session.id
|
||||
const affinity = SessionAffinity.get(session)
|
||||
const base = LLM.request({
|
||||
model: model.model,
|
||||
http: {
|
||||
|
||||
@@ -207,6 +207,10 @@ export const Plugin = {
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
invocation.env.AGENT = "1"
|
||||
invocation.env.OPENCODE = "1"
|
||||
invocation.env.AI_AGENT ||= "opencode"
|
||||
invocation.env.OPENCODE_SESSION_ID = context.sessionID
|
||||
finalTimeout = yield* prepare(invocation, context)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -88,6 +88,11 @@ const responsesEffort = (effort: string): Overlay => ({
|
||||
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING },
|
||||
})
|
||||
|
||||
const xaiResponses: Protocol = (_, support) => {
|
||||
if (support.type !== "effort") return []
|
||||
return efforts(support.values ?? EFFORTS, responsesEffort)
|
||||
}
|
||||
|
||||
const cloudflareAIGateway: Protocol = (model, support) => {
|
||||
const id = modelID(model)
|
||||
if (id.startsWith("openai/")) return openaiResponses(model, support)
|
||||
@@ -557,7 +562,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/mistral": openaiChat,
|
||||
"@opencode/ai/providers/moonshot/chat": moonshotChat,
|
||||
"@opencode/ai/providers/togetherai": openaiChat,
|
||||
"@opencode/ai/providers/xai": openaiChat,
|
||||
"@opencode/ai/providers/xai": xaiResponses,
|
||||
"@opencode/ai/providers/zai/chat": zaiChat,
|
||||
"@opencode/ai/providers/zai-coding-plan/chat": zaiChat,
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
|
||||
import { SessionSchema } from "@opencode/core/session/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
@@ -16,7 +15,6 @@ import { readInitial, readUpdate } from "../lib/instructions"
|
||||
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
@@ -40,21 +38,20 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
"",
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Current conversation session ID: ${sessionID}`,
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
@@ -64,10 +61,10 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* readUpdate(yield* context.load(sessionID), initialized)
|
||||
const refreshed = yield* readUpdate(yield* context.load(), initialized)
|
||||
|
||||
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
|
||||
}),
|
||||
@@ -77,10 +74,10 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect((yield* readUpdate(yield* context.load(sessionID), initialized)).changed).toBe(false)
|
||||
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Compatibility, ID, Info, Model, VariantID } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Variant } from "@opencode/core/variant"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -580,6 +581,29 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends xAI Responses effort variants with reasoning summaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const base = model("@opencode/ai/providers/xai", { providerID: Provider.ID.make("xai"), modelID: "grok-4.6" })
|
||||
const catalog = Info.make({
|
||||
...base,
|
||||
variants: Variant.resolve(base, [{ type: "effort", values: ["low", "high"] }]),
|
||||
})
|
||||
const resolved = yield* ModelResolver.resolveModel(
|
||||
catalog,
|
||||
VariantID.make("high"),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(resolved.route.id).toBe("xai-responses")
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
expect(prepared.body).not.toHaveProperty("reasoning_effort")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
@@ -1282,7 +1306,7 @@ describe("ModelResolver", () => {
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
expect(String(mistral.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.id).toBe("xai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
store: false,
|
||||
|
||||
@@ -1059,7 +1059,7 @@ describe("ModelsDevPlugin", () => {
|
||||
expect(grok?.variants).toEqual(
|
||||
["low", "medium", "high"].map((id) => ({
|
||||
id: Model.VariantID.make(id),
|
||||
settings: { reasoningEffort: id },
|
||||
settings: { reasoningEffort: id, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,11 +4,19 @@ import { Integration } from "@opencode/core/integration"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
const seed = Effect.fn(function* (fn: (provider: Provider.MutableInfo) => void) {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(providerID, fn))
|
||||
return catalog
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -46,4 +54,55 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("requests only the gateway ID when the environment provides the account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form: [expect.objectContaining({ type: "string", key: "gatewayId", required: true })],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives the provider its environment account and gateway IDs", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed(() => {})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({ accountId: "account", gatewayId: "gateway" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers configured IDs over environment IDs", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-account", CLOUDFLARE_GATEWAY_ID: "env-gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
provider.settings = { accountId: "configured-account", gatewayId: "configured-gateway" }
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({
|
||||
accountId: "configured-account",
|
||||
gatewayId: "configured-gateway",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("leaves a configured endpoint URL untouched", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
provider.settings = { baseURL: "https://proxy.example/v1" }
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({ baseURL: "https://proxy.example/v1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
it.effect("uses configured account ID over env account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
@@ -129,7 +129,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.get(providerID))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/configured-acct/ai/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -183,10 +183,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies child-session steps as subagent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
const ids = yield* sessions()
|
||||
const event = yield* modelRequest(ids.child, "primary")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-subagent",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"X-Interaction-Id": ids.parent,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
@@ -207,10 +208,11 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies compaction requests by kind rather than agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
const ids = yield* sessions()
|
||||
const event = yield* modelRequest(ids.child, "compaction", "build")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-compaction",
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"X-Interaction-Id": ids.parent,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
@@ -41,10 +41,14 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
|
||||
const request = Effect.fn(function* (
|
||||
providerID: Provider.ID,
|
||||
baseURL: string,
|
||||
sessionID = Session.ID.make("ses_test"),
|
||||
) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
sessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
kind: "primary",
|
||||
@@ -156,6 +160,12 @@ describe("OpenAIPlugin", () => {
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const sessions = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const parent = yield* sessions.create({ location: { directory: location.directory } })
|
||||
const child = yield* sessions.create({ parentID: parent.id })
|
||||
const childRequest = yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id)
|
||||
expect(childRequest.headers).toMatchObject({ "session-id": parent.id })
|
||||
const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
|
||||
@@ -236,7 +236,7 @@ const setup = Effect.gen(function* () {
|
||||
db,
|
||||
bus,
|
||||
session,
|
||||
instructions: yield* instructionBuiltIns.load(sessionID),
|
||||
instructions: yield* instructionBuiltIns.load(),
|
||||
instances: Instance.Service.of({
|
||||
// Generation only exercises the Location's model context.
|
||||
provide: () =>
|
||||
|
||||
@@ -87,7 +87,7 @@ import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, modelHost, host, noProviders } from "./plugin/host"
|
||||
import { CodeModeInstructions } from "@opencode/core/codemode/instructions"
|
||||
|
||||
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
||||
const emptyCodeMode = `${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}\n\n`
|
||||
type ToolBarrier = {
|
||||
readonly count: number
|
||||
readonly started: Deferred.Deferred<void>
|
||||
@@ -128,6 +128,7 @@ const undersizedContextModel = testModel("undersized-context", { context: 1, out
|
||||
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
|
||||
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
|
||||
const smallWindowModel = testModel("small-window", { context: 64_000, output: 16_000 })
|
||||
const largeOutputModel = testModel("large-output", { context: 1_000_000, output: 1_000_000 })
|
||||
|
||||
test("calculates step cost using the matching context tier", () => {
|
||||
expect(
|
||||
@@ -1874,8 +1875,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Second")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
])
|
||||
expect(systemTexts(s.requests[1])).toContainEqual(expect.stringContaining("Reviewer skills"))
|
||||
})
|
||||
@@ -1897,7 +1898,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("First")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -3424,6 +3425,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 200)
|
||||
})
|
||||
|
||||
scenario("caps the output limit a large model advertises", function* (s) {
|
||||
s.currentModel = largeOutputModel
|
||||
yield* s.llm.push(TestLLM.text("Answer", "text-large-output"))
|
||||
yield* s.runPrompt("Question")
|
||||
|
||||
expect(s.requests[0]?.generation?.maxTokens).toBe(256_000)
|
||||
})
|
||||
|
||||
scenario("gives the summary its full output limit when the conversation overshot the threshold", function* (s) {
|
||||
// The conversation overshot the threshold, so the prepared summary request exceeds the budget and `deliver`
|
||||
// shrinks it before sending. The output limit must follow the budget, not the oversized prepared request.
|
||||
|
||||
@@ -956,6 +956,59 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"identifies agent shell commands without inheriting a stale session ID",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.environment({
|
||||
sessionID,
|
||||
variables: { AGENT: "0", OPENCODE: "0", AI_AGENT: "", OPENCODE_SESSION_ID: "stale" },
|
||||
})
|
||||
const command = isWindows
|
||||
? '[Console]::Out.Write("$env:AGENT|$env:OPENCODE|$env:AI_AGENT|$env:OPENCODE_SESSION_ID")'
|
||||
: 'printf %s "$AGENT|$OPENCODE|$AI_AGENT|$OPENCODE_SESSION_ID"'
|
||||
const settled = yield* executeTool(registry, call({ command }))
|
||||
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: `1|1|opencode|${sessionID}` })
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"preserves an outer AI_AGENT marker in agent shell commands",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.environment({ sessionID, variables: { AI_AGENT: "outer-agent" } })
|
||||
const command = isWindows ? '[Console]::Out.Write($env:AI_AGENT)' : 'printf %s "$AI_AGENT"'
|
||||
const settled = yield* executeTool(registry, call({ command }))
|
||||
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "outer-agent" })
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("resolves a relative workdir from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -126,6 +126,26 @@ test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("spells xAI Responses variants with catalog effort levels", () => {
|
||||
const supports: Variant.Support[] = [{ type: "effort", values: ["low", "medium", "high", "xhigh"] }]
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), supports)).toEqual(
|
||||
["low", "medium", "high", "xhigh"].map((effort) => ({
|
||||
id: effort,
|
||||
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
})),
|
||||
)
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.3"), [{ type: "effort", values: ["none", "low"] }])).toEqual([
|
||||
{
|
||||
id: "none",
|
||||
settings: { reasoningEffort: "none", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
},
|
||||
{ id: "low", settings: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] } },
|
||||
])
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), [{ type: "effort" }]).map((item) => item.id)).toEqual([
|
||||
"low", "medium", "high",
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Chat Completions variants for direct providers", () => {
|
||||
expect(
|
||||
resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -53,7 +53,7 @@
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "44.4.3",
|
||||
"electron": "44.4.5",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
|
||||
@@ -44,7 +44,14 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
? path.join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version,
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--hostname", "0.0.0.0", "--port", "0"] : [])],
|
||||
// A fixed port makes a second contender fail to bind and back off; port 0 never collides, so two
|
||||
// services could boot against the same database.
|
||||
command: [
|
||||
...cli.command,
|
||||
"serve",
|
||||
"--service",
|
||||
...(isolated ? ["--hostname", "0.0.0.0", "--port", String(0x0c0c)] : []),
|
||||
],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -13,10 +13,7 @@ for (const split of [false, true]) {
|
||||
await expect(additions).toHaveText(["select-text"])
|
||||
await expect(root.locator('[data-line-type="context"] [data-diff-span]')).toHaveCount(0)
|
||||
await expect(root.locator('[data-line-type="change-deletion"] [data-diff-span]')).toHaveText([
|
||||
'"http" in',
|
||||
"? error.reason.",
|
||||
"response?.",
|
||||
": undefined",
|
||||
'"http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined',
|
||||
])
|
||||
await expect(additions).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
})
|
||||
@@ -27,8 +24,7 @@ for (const split of [false, true]) {
|
||||
const root = await mount("components-session-review--large-file", { args: { split, source } })
|
||||
const line = root.locator('[data-line][data-line-type="change-addition"]')
|
||||
await expect(line).toHaveText("export const value = 'after'")
|
||||
// Plain first paint is not proof that the worker kept inline diffs disabled.
|
||||
await expect(line.locator('[style*="--syntax-"]')).not.toHaveCount(0)
|
||||
await expect(line.locator('[style*="--syntax-"]')).toHaveCount(0)
|
||||
await expect(root.locator("[data-diff-span]")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
|
||||
story("renders the line comment content editor and compact actions", async ({ mount }) => {
|
||||
const root = await mount("ui-line-comment--editor-filled")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
|
||||
const editor = root.getByRole("textbox")
|
||||
expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA")
|
||||
await editor.fill("Updated comment\nwith context")
|
||||
await expect(editor).toHaveValue("Updated comment\nwith context")
|
||||
await editor.fill("x")
|
||||
await editor.press("Backspace")
|
||||
await expect(editor).toHaveValue("")
|
||||
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
|
||||
await expect(root.locator("textarea")).toHaveCount(1)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-label"]')).toHaveCount(0)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-footer-meta"]')).toHaveCount(0)
|
||||
await expect(root.locator('[data-slot="line-comment-v2-shell"]')).toHaveCSS("padding", "0px")
|
||||
await expect(editor).toHaveCSS("border-top-width", "0px")
|
||||
await expect(editor).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
await expect(editor).toHaveCSS("padding", "12px")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost-muted")
|
||||
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-size", "small")
|
||||
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-variant", "submit")
|
||||
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-size", "small")
|
||||
})
|
||||
|
||||
story("preserves native undo in the line comment editor", async ({ mount }) => {
|
||||
const root = await mount("ui-line-comment--editor")
|
||||
const editor = root.getByRole("textbox")
|
||||
await editor.pressSequentially("undo me")
|
||||
await editor.press("Meta+z")
|
||||
await expect(editor).toHaveValue("")
|
||||
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -48,3 +48,154 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
story(`offers a comment action for selected review text in ${direction}`, async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel", { globals: { direction } })
|
||||
const action = page.getByRole("button", { name: "Add comment", exact: true })
|
||||
await expect(async () => {
|
||||
await root.locator('[data-line-type="change-addition"] [data-diff-span]').selectText()
|
||||
await expect(action).toBeVisible()
|
||||
}).toPass()
|
||||
await expect(root.getByRole("textbox")).not.toBeVisible()
|
||||
|
||||
await expect(action).toHaveAttribute("data-variant", "submit")
|
||||
await expect(action).toHaveCSS("z-index", "110")
|
||||
const box = await action.boundingBox()
|
||||
const code = root.locator("[data-code]").first()
|
||||
const gutterRight = await code.evaluate((element) => element.firstElementChild?.getBoundingClientRect().right)
|
||||
expect((box?.x ?? 0) - (gutterRight ?? 0)).toBeCloseTo(8, 0)
|
||||
expect(box?.x).toBeGreaterThanOrEqual(0)
|
||||
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => document.documentElement.clientWidth),
|
||||
)
|
||||
await action.click()
|
||||
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("")
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator('[data-line="2"][data-line-type="change-addition"]')).toHaveAttribute(
|
||||
"data-selected-line",
|
||||
/.*/,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["up", "down"] as const) {
|
||||
story(
|
||||
`positions the comment action with ${direction === "up" ? "an upward" : "a downward"} selection`,
|
||||
async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
const action = page.getByRole("button", { name: "Add comment", exact: true })
|
||||
await expect(async () => {
|
||||
await root.getByText("export const first = 1", { exact: true }).evaluate((element, value) => {
|
||||
const root = element.getRootNode()
|
||||
if (!(root instanceof ShadowRoot)) throw new Error("Expected a shadow root")
|
||||
const text = (line: number) => {
|
||||
const row = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!row) throw new Error(`Missing line ${line}`)
|
||||
const node = document.createTreeWalker(row, NodeFilter.SHOW_TEXT).nextNode()
|
||||
if (!node) throw new Error(`Missing text for line ${line}`)
|
||||
return node
|
||||
}
|
||||
const first = text(1)
|
||||
const last = text(3)
|
||||
const selection = window.getSelection()
|
||||
if (!selection) throw new Error("Missing selection")
|
||||
if (value === "up") {
|
||||
selection.setBaseAndExtent(last, last.textContent?.length ?? 0, first, 0)
|
||||
} else {
|
||||
selection.setBaseAndExtent(first, 0, last, last.textContent?.length ?? 0)
|
||||
}
|
||||
document.dispatchEvent(new Event("selectionchange"))
|
||||
}, direction)
|
||||
await expect(action).toHaveAttribute("data-placement", direction === "up" ? "top" : "bottom")
|
||||
await expect(action).toHaveClass(/transition-transform/)
|
||||
await expect(action).toHaveClass(/ease-out/)
|
||||
await expect(action).not.toHaveClass(/fade-in/)
|
||||
await expect
|
||||
.poll(() =>
|
||||
action.evaluate((button) => {
|
||||
const host = document.querySelector('[data-component="file"]')?.querySelector("diffs-container")
|
||||
const root = host?.shadowRoot
|
||||
if (!root) return NaN
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ??
|
||||
window.getSelection()
|
||||
const source = (
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[]
|
||||
}
|
||||
)?.getComposedRanges?.({ shadowRoots: [root] })?.[0]
|
||||
if (!source) return NaN
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
const selected = range.getBoundingClientRect()
|
||||
const action = button.getBoundingClientRect()
|
||||
return button.getAttribute("data-placement") === "top"
|
||||
? selected.top - action.bottom
|
||||
: action.top - selected.bottom
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(8, 0)
|
||||
}).toPass()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("leaves a review code click as regular text interaction", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
await root.locator('[data-line-type="change-addition"] [data-diff-span]').click()
|
||||
await expect(root.getByRole("textbox")).not.toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps the text selection action visible past the end of a file", async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-file-comments")
|
||||
const action = page.getByRole("button", { name: "Add comment", exact: true })
|
||||
await expect(async () => {
|
||||
await root.getByText("export const last = 2", { exact: true }).selectText()
|
||||
await expect(action).toBeVisible()
|
||||
}).toPass()
|
||||
|
||||
const file = root.locator('[data-component="file"]')
|
||||
await expect(action).toHaveCSS("position", "fixed")
|
||||
expect(await action.evaluate((element) => element.closest('[data-component="file"]'))).toBeNull()
|
||||
const actionBox = await action.boundingBox()
|
||||
const fileBox = await file.boundingBox()
|
||||
expect((actionBox?.y ?? 0) + (actionBox?.height ?? 0)).toBeGreaterThan(
|
||||
(fileBox?.y ?? 0) + (fileBox?.height ?? 0),
|
||||
)
|
||||
})
|
||||
|
||||
story("keeps direct line-number range comments in the review panel", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
await root.locator('[data-column-number="1"]').dragTo(root.locator('[data-column-number="3"]'))
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator("[data-selected-line]")).not.toHaveCount(0)
|
||||
})
|
||||
|
||||
story("keeps the direct gutter comment action in the review panel", async ({ mount }) => {
|
||||
const root = await mount("components-session-review--interactive-comments-panel")
|
||||
const comment = root.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(async () => {
|
||||
await root.getByText("export const first = 1", { exact: true }).hover()
|
||||
await expect(comment).toBeVisible()
|
||||
}).toPass()
|
||||
expect(await comment.evaluate((element) => (element as HTMLElement).style.background)).toBe(
|
||||
"var(--v2-background-bg-inverse)",
|
||||
)
|
||||
expect(await comment.evaluate((element) => (element as HTMLElement).style.left)).toBe("-4px")
|
||||
await expect(comment).toHaveCSS("z-index", "110")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await comment.boundingBox()
|
||||
const gutterRight = await comment.evaluate(
|
||||
(element) => element.parentElement?.assignedSlot?.parentElement?.parentElement?.getBoundingClientRect().right,
|
||||
)
|
||||
return (box?.x ?? 0) + (box?.width ?? 0) - (gutterRight ?? 0)
|
||||
})
|
||||
.toBe(-4)
|
||||
await comment.dispatchEvent("click")
|
||||
await expect(root.getByRole("textbox")).toBeVisible()
|
||||
await expect(root.locator('[data-line="1"]')).toHaveAttribute("data-selected-line", /.*/)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
|
||||
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
|
||||
story("renders the moved location notice as a timeline divider", async ({ mount, page }) => {
|
||||
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
|
||||
await page.setViewportSize({ width: 480, height: 720 })
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } })
|
||||
@@ -14,12 +14,10 @@ story("renders the moved location notice in its compact timeline style", async (
|
||||
await expect(value).toHaveText(directory)
|
||||
await expect(notice).not.toContainText("·")
|
||||
await expect(notice.locator("svg")).toHaveCount(0)
|
||||
await expect(notice).toHaveCSS("height", "28px")
|
||||
await expect(notice).toHaveCSS("gap", "8px")
|
||||
await expect(notice).toHaveCSS("padding-top", "4px")
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(notice).toHaveCSS("padding-top", "8px")
|
||||
await expect(notice).toHaveCSS("padding-bottom", "8px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("font-weight", "440")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -13,7 +13,7 @@ test("renders large diffs as plain text without changing ordinary highlighting",
|
||||
await page.goto("/")
|
||||
await page.waitForFunction(() => !!window.highlighting)
|
||||
const ordinary = await page.evaluate(() => window.highlighting.mount(1))
|
||||
expect(ordinary.options).toMatchObject({ lineDiffType: "word-alt", maxLineDiffLength: 1000, tokenizeMaxLineLength: 1000 })
|
||||
expect(ordinary.options).toMatchObject({ lineDiffType: "word-line", maxLineDiffLength: 1000, tokenizeMaxLineLength: 1000 })
|
||||
expect(ordinary.syntaxSpans).toBeGreaterThan(0)
|
||||
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 201")
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ async function mount(revision: number) {
|
||||
const offset = messages.length
|
||||
const start = performance.now()
|
||||
active = normalize({ ...input, additions: large ? 120 : 12, deletions: large ? 120 : 12 })
|
||||
const pool = getWorkerPool(large ? "none" : "word-alt")!
|
||||
const pool = getWorkerPool(large ? "none" : "word-line")!
|
||||
let firstReady = 0
|
||||
let rendered = 0
|
||||
let finish!: (value: Measurement) => void
|
||||
@@ -128,7 +128,7 @@ async function mount(revision: number) {
|
||||
|
||||
export const highlighting = {
|
||||
mount,
|
||||
configure(options: Partial<WorkerRenderingOptions>) { return getWorkerPool(large ? "none" : "word-alt")!.setRenderOptions(options) },
|
||||
configure(options: Partial<WorkerRenderingOptions>) { return getWorkerPool(large ? "none" : "word-line")!.setRenderOptions(options) },
|
||||
unmount() { dispose?.(); dispose = undefined; host.scrollTop = 0 },
|
||||
contents() { return active && { before: text(active, "deletions"), after: text(active, "additions") } },
|
||||
input: { before, after: inputs.map((input) => input.after) },
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T, undefined> | PreloadFileDiffResult<T, undefined>
|
||||
|
||||
type SSRDiffFileProps<T> = DiffFileProps<T> & {
|
||||
preloadedDiff: DiffPreload<T>
|
||||
@@ -47,6 +47,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
"textSelectionAction",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
@@ -119,7 +120,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate(
|
||||
local.fileDiff
|
||||
local.fileDiff || !local.before || !local.after
|
||||
? {
|
||||
fileDiff: local.fileDiff,
|
||||
lineAnnotations: annotations,
|
||||
@@ -128,12 +129,11 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||
}
|
||||
: {
|
||||
oldFile: local.before
|
||||
? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
|
||||
: local.before,
|
||||
newFile: local.after
|
||||
? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
|
||||
: local.after,
|
||||
oldFile: {
|
||||
...local.before,
|
||||
contents: typeof local.before.contents === "string" ? local.before.contents : "",
|
||||
},
|
||||
newFile: { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" },
|
||||
lineAnnotations: annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
|
||||
@@ -21,10 +21,13 @@ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pi
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
|
||||
import { createFileFind } from "../pierre/file-find"
|
||||
import { LINE_COMMENT_ACTION_GAP } from "../pierre/comment-hover"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
@@ -48,6 +51,8 @@ import { FileMedia, type FileMediaOptions } from "./file-media"
|
||||
import { FileSearchBar } from "./file-search"
|
||||
|
||||
const VIRTUALIZE_BYTES = 500_000
|
||||
const TEXT_SELECTION_ACTION_HEIGHT = 24
|
||||
const TEXT_SELECTION_ACTION_GAP = 8
|
||||
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
@@ -65,6 +70,9 @@ type SharedProps<T> = {
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
media?: FileMediaOptions
|
||||
search?: FileSearchControl
|
||||
textSelectionAction?: {
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSearchHandle = {
|
||||
@@ -75,17 +83,17 @@ export type FileSearchControl = {
|
||||
register: (handle: FileSearchHandle | null) => void
|
||||
}
|
||||
|
||||
export type TextFileProps<T = {}> = FileOptions<T> &
|
||||
export type TextFileProps<T = {}> = FileOptions<T, undefined> &
|
||||
SharedProps<T> & {
|
||||
mode: "text"
|
||||
file: FileContents
|
||||
annotations?: LineAnnotation<T>[]
|
||||
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
||||
preloadedDiff?: PreloadMultiFileDiffResult<T, undefined>
|
||||
}
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T, undefined> | PreloadFileDiffResult<T, undefined>
|
||||
|
||||
type DiffBaseProps<T> = FileDiffOptions<T> &
|
||||
type DiffBaseProps<T> = FileDiffOptions<T, undefined> &
|
||||
SharedProps<T> & {
|
||||
mode: "diff"
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
@@ -123,6 +131,7 @@ const sharedKeys = [
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
"textSelectionAction",
|
||||
] as const
|
||||
|
||||
const textKeys = ["file", ...sharedKeys] as const
|
||||
@@ -140,6 +149,7 @@ type MouseHit = {
|
||||
|
||||
type ViewerConfig = {
|
||||
enableLineSelection: () => boolean
|
||||
textSelectionAction: () => { label: string } | undefined
|
||||
selectedLines: () => SelectedLineRange | null | undefined
|
||||
commentedLines: () => SelectedLineRange[]
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
@@ -148,6 +158,14 @@ type ViewerConfig = {
|
||||
lineFromMouseEvent: (event: MouseEvent) => MouseHit
|
||||
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
|
||||
updateSelection: (preserveTextSelection: boolean) => void
|
||||
readTextSelection: () =>
|
||||
| {
|
||||
range: SelectedLineRange
|
||||
text: Range
|
||||
direction: "up" | "down" | "same"
|
||||
gutterRight?: number
|
||||
}
|
||||
| undefined
|
||||
buildDragSelection: () => SelectedLineRange | undefined
|
||||
buildClickSelection: () => SelectedLineRange | undefined
|
||||
onDragStart: (hit: MouseHit) => void
|
||||
@@ -162,6 +180,7 @@ function useFileViewer(config: ViewerConfig) {
|
||||
let overlay!: HTMLDivElement
|
||||
let selectionFrame: number | undefined
|
||||
let dragFrame: number | undefined
|
||||
let textSelectionFrame: number | undefined
|
||||
let dragStart: number | undefined
|
||||
let dragEnd: number | undefined
|
||||
let dragMoved = false
|
||||
@@ -171,6 +190,14 @@ function useFileViewer(config: ViewerConfig) {
|
||||
const ready = createReadyWatcher()
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
const [textSelection, setTextSelection] = createSignal<{
|
||||
range: SelectedLineRange
|
||||
rect: DOMRect
|
||||
label: string
|
||||
below: boolean
|
||||
gutterEdge: number
|
||||
}>()
|
||||
const hasTextSelection = createMemo(() => textSelection() !== undefined)
|
||||
|
||||
const getRoot = () => getViewerRoot(container)
|
||||
const getHost = () => getViewerHost(container)
|
||||
@@ -204,6 +231,54 @@ function useFileViewer(config: ViewerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const updateTextSelection = () => {
|
||||
textSelectionFrame = undefined
|
||||
const action = config.textSelectionAction()
|
||||
if (!action) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
const selected = config.readTextSelection()
|
||||
if (!selected) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = selected.text.getBoundingClientRect()
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
setTextSelection(undefined)
|
||||
return
|
||||
}
|
||||
const roomBelow = rect.bottom + TEXT_SELECTION_ACTION_HEIGHT + TEXT_SELECTION_ACTION_GAP <= window.innerHeight
|
||||
const roomAbove = rect.top - TEXT_SELECTION_ACTION_HEIGHT - TEXT_SELECTION_ACTION_GAP >= 0
|
||||
const preferBelow = selected.direction !== "up"
|
||||
const below = preferBelow ? roomBelow || !roomAbove : !roomAbove && roomBelow
|
||||
const gutterEdge = selected.gutterRight ?? wrapper.getBoundingClientRect().left
|
||||
setTextSelection({ range: selected.range, rect, label: action.label, below, gutterEdge })
|
||||
}
|
||||
|
||||
const scheduleTextSelectionUpdate = () => {
|
||||
if (textSelectionFrame !== undefined) return
|
||||
textSelectionFrame = requestAnimationFrame(updateTextSelection)
|
||||
}
|
||||
|
||||
const clearTextSelection = () => {
|
||||
setTextSelection(undefined)
|
||||
const root = getRoot()
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null } | undefined)?.getSelection?.() ??
|
||||
window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
|
||||
const activateTextSelection = () => {
|
||||
const selected = textSelection()
|
||||
if (!selected) return
|
||||
clearTextSelection()
|
||||
config.setSelectedLines(selected.range)
|
||||
config.onLineSelectionEnd(selected.range)
|
||||
}
|
||||
|
||||
// -- mouse handlers --
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
@@ -218,6 +293,11 @@ function useFileViewer(config: ViewerConfig) {
|
||||
if (hit.line === undefined) return
|
||||
|
||||
bridge.begin(false, hit.line)
|
||||
if (config.textSelectionAction()) {
|
||||
setTextSelection(undefined)
|
||||
if (lastSelection) config.setSelectedLines(null)
|
||||
return
|
||||
}
|
||||
dragStart = hit.line
|
||||
dragEnd = hit.line
|
||||
dragMoved = false
|
||||
@@ -250,6 +330,10 @@ function useFileViewer(config: ViewerConfig) {
|
||||
const handleMouseUp = () => {
|
||||
if (!config.enableLineSelection()) return
|
||||
if (bridge.finish() === "numbers") return
|
||||
if (config.textSelectionAction()) {
|
||||
scheduleTextSelectionUpdate()
|
||||
return
|
||||
}
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
@@ -284,6 +368,10 @@ function useFileViewer(config: ViewerConfig) {
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
if (!config.enableLineSelection()) return
|
||||
if (config.textSelectionAction()) {
|
||||
scheduleTextSelectionUpdate()
|
||||
return
|
||||
}
|
||||
if (dragStart === undefined) return
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
@@ -326,7 +414,9 @@ function useFileViewer(config: ViewerConfig) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
config.setSelectedLines(config.selectedLines() ?? null)
|
||||
const selected = config.selectedLines() ?? null
|
||||
if (selected && config.textSelectionAction()) clearTextSelection()
|
||||
config.setSelectedLines(selected)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -338,14 +428,26 @@ function useFileViewer(config: ViewerConfig) {
|
||||
makeEventListener(document, "selectionchange", handleSelectionChange)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!config.enableLineSelection() || !config.textSelectionAction() || !hasTextSelection()) return
|
||||
makeEventListener(document, "scroll", scheduleTextSelectionUpdate, true)
|
||||
makeEventListener(window, "resize", scheduleTextSelectionUpdate)
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.key !== "Escape") return
|
||||
clearTextSelection()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
|
||||
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
|
||||
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
|
||||
if (textSelectionFrame !== undefined) cancelAnimationFrame(textSelectionFrame)
|
||||
|
||||
selectionFrame = undefined
|
||||
dragFrame = undefined
|
||||
textSelectionFrame = undefined
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
@@ -393,15 +495,21 @@ function useFileViewer(config: ViewerConfig) {
|
||||
getHost,
|
||||
find,
|
||||
scheduleSelectionUpdate,
|
||||
textSelection,
|
||||
activateTextSelection,
|
||||
}
|
||||
}
|
||||
|
||||
type Viewer = ReturnType<typeof useFileViewer>
|
||||
|
||||
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
|
||||
type ModeAdapter = Omit<
|
||||
ViewerConfig,
|
||||
"enableLineSelection" | "textSelectionAction" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
|
||||
>
|
||||
|
||||
type ModeConfig = {
|
||||
enableLineSelection: () => boolean
|
||||
textSelectionAction: () => { label: string } | undefined
|
||||
selectedLines: () => SelectedLineRange | null | undefined
|
||||
commentedLines: () => SelectedLineRange[] | undefined
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
@@ -424,6 +532,7 @@ type VirtualStrategy = {
|
||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||
return useFileViewer({
|
||||
enableLineSelection: config.enableLineSelection,
|
||||
textSelectionAction: config.textSelectionAction,
|
||||
selectedLines: config.selectedLines,
|
||||
commentedLines: () => config.commentedLines() ?? [],
|
||||
onLineSelectionEnd: config.onLineSelectionEnd,
|
||||
@@ -728,6 +837,38 @@ function ViewerShell(props: {
|
||||
</Show>
|
||||
<div ref={(el) => (props.viewer.container = el)} />
|
||||
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
|
||||
<Show when={props.viewer.textSelection()}>
|
||||
{(selection) => (
|
||||
<Portal>
|
||||
<Button
|
||||
data-slot="file-text-selection-action"
|
||||
data-placement={selection().below ? "bottom" : "top"}
|
||||
size="small"
|
||||
variant="submit"
|
||||
class="z-[110] whitespace-nowrap motion-safe:transition-transform duration-100 ease-out motion-reduce:transition-none"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${selection().gutterEdge + LINE_COMMENT_ACTION_GAP}px`,
|
||||
top: `${selection().below ? selection().rect.bottom : selection().rect.top}px`,
|
||||
transform: selection().below
|
||||
? `translateY(${TEXT_SELECTION_ACTION_GAP}px)`
|
||||
: `translateY(calc(-100% - ${TEXT_SELECTION_ACTION_GAP}px))`,
|
||||
}}
|
||||
onPointerDown={(event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={props.viewer.activateTextSelection}
|
||||
>
|
||||
{selection().label}
|
||||
</Button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -845,6 +986,23 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
if (!preserveTextSelection || !selected.text) return
|
||||
restoreShadowTextSelection(root, selected.text)
|
||||
},
|
||||
readTextSelection: () => {
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return
|
||||
const selected = readShadowLineSelection({
|
||||
root,
|
||||
lineForNode: findFileLineNumber,
|
||||
sideForNode: findCodeSelectionSide,
|
||||
preserveTextSelection: true,
|
||||
})
|
||||
if (!selected?.text) return
|
||||
return {
|
||||
range: selected.range,
|
||||
text: selected.text,
|
||||
direction: selected.direction,
|
||||
gutterRight: selected.gutterRight,
|
||||
}
|
||||
},
|
||||
buildDragSelection: () => {
|
||||
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
||||
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
|
||||
@@ -862,6 +1020,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
viewer = useModeViewer(
|
||||
{
|
||||
enableLineSelection: () => props.enableLineSelection === true,
|
||||
textSelectionAction: () => local.textSelectionAction,
|
||||
selectedLines: () => local.selectedLines,
|
||||
commentedLines: () => local.commentedLines,
|
||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||
@@ -957,7 +1116,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
let instance: FileDiff<T> | undefined
|
||||
let instanceVirtualizer: Virtualizer | undefined
|
||||
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
|
||||
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
|
||||
let instanceVirtualHunkSeparators: FileDiffOptions<T, undefined>["hunkSeparators"] | undefined
|
||||
let instanceFileDiff: FileDiffMetadata | undefined
|
||||
let instanceBefore: FileContents | undefined
|
||||
let instanceAfter: FileContents | undefined
|
||||
@@ -1008,6 +1167,20 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
|
||||
setSelectedLines(selected.range)
|
||||
},
|
||||
readTextSelection: () => {
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return
|
||||
const selected = readShadowLineSelection({
|
||||
root,
|
||||
lineForNode: findDiffLineNumber,
|
||||
sideForNode: diffSelectionSide,
|
||||
preserveTextSelection: true,
|
||||
})
|
||||
if (!selected?.text) return
|
||||
const range = fixDiffSelection(root, selected.range)
|
||||
if (!range) return
|
||||
return { range, text: selected.text, direction: selected.direction, gutterRight: selected.gutterRight }
|
||||
},
|
||||
buildDragSelection: () => {
|
||||
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
||||
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
|
||||
@@ -1038,6 +1211,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
viewer = useModeViewer(
|
||||
{
|
||||
enableLineSelection: () => props.enableLineSelection === true,
|
||||
textSelectionAction: () => local.textSelectionAction,
|
||||
selectedLines: () => local.selectedLines,
|
||||
commentedLines: () => local.commentedLines,
|
||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||
@@ -1066,7 +1240,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
lineDiffType: "none",
|
||||
maxLineDiffLength: 0,
|
||||
tokenizeMaxLineLength: 1,
|
||||
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
|
||||
} satisfies Pick<FileDiffOptions<T, undefined>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
|
||||
|
||||
const lineCallbacks = createLineCallbacks({
|
||||
viewer,
|
||||
@@ -1076,7 +1250,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
|
||||
})
|
||||
|
||||
const options = createMemo<FileDiffOptions<T>>(() => {
|
||||
const options = createMemo<FileDiffOptions<T, undefined>>(() => {
|
||||
const base = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
@@ -1112,7 +1286,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
// Worker render options override per-viewer options, including the large-file fallback.
|
||||
const workerPool = getWorkerPool(large() ? "none" : "word-alt")
|
||||
const workerPool = getWorkerPool(large() ? "none" : "word-line")
|
||||
const virtualizer = virtuals.get()
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
|
||||
import { File } from "./file"
|
||||
import { SessionReview, type SessionReviewComment } from "./session-review"
|
||||
import { SessionReviewFilePreviewV2 } from "../v2/components/session-review-file-preview-v2"
|
||||
|
||||
function ReviewStory(props: { split?: boolean }) {
|
||||
return (
|
||||
@@ -80,6 +81,53 @@ function InteractiveCommentsStory() {
|
||||
|
||||
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
|
||||
|
||||
function InteractiveCommentsV2Story() {
|
||||
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
|
||||
const file = "src/review.ts"
|
||||
const diff = {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch:
|
||||
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
|
||||
}
|
||||
return (
|
||||
<CurrentSessionProviders document={editThenTestDocument}>
|
||||
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-auto bg-background-base">
|
||||
<SessionReviewFilePreviewV2
|
||||
file={file}
|
||||
diff={diff}
|
||||
diffStyle="unified"
|
||||
comments={state.comments}
|
||||
onLineComment={(comment) =>
|
||||
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CurrentSessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export const InteractiveCommentsPanel = { render: () => <InteractiveCommentsV2Story /> }
|
||||
|
||||
export const InteractiveFileComments = {
|
||||
render: () => (
|
||||
<CurrentSessionProviders document={editThenTestDocument}>
|
||||
<div class="h-screen overflow-auto bg-background-base">
|
||||
<div class="relative overflow-hidden pb-40">
|
||||
<File
|
||||
mode="text"
|
||||
file={{ name: "review.ts", contents: "export const first = 1\nexport const last = 2" }}
|
||||
enableLineSelection
|
||||
textSelectionAction={{ label: "Add comment" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CurrentSessionProviders>
|
||||
),
|
||||
}
|
||||
|
||||
const gitDiffs = [
|
||||
{
|
||||
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
|
||||
|
||||
@@ -64,12 +64,12 @@ export type SessionReviewCommentActions = {
|
||||
export type SessionReviewFocus = { file: string; id: string }
|
||||
|
||||
type RawReviewDiff = (PresentationFileDiff | FileDiffInfo) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<unknown>
|
||||
preloaded?: PreloadMultiFileDiffResult<unknown, undefined>
|
||||
}
|
||||
type ReviewDiff = ((PresentationFileDiff & { file: string }) | FileDiffInfo) & {
|
||||
preloaded?: PreloadMultiFileDiffResult<unknown>
|
||||
preloaded?: PreloadMultiFileDiffResult<unknown, undefined>
|
||||
}
|
||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<unknown> }
|
||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<unknown, undefined> }
|
||||
|
||||
function diff(value: unknown): value is ReviewDiff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
export function TimelineSeparator(props: { label: string; providerID?: string; variant?: string }) {
|
||||
export function TimelineSeparator(props: {
|
||||
label: string
|
||||
value?: string
|
||||
tooltip?: string
|
||||
providerID?: string
|
||||
variant?: string
|
||||
}) {
|
||||
const label = () => (
|
||||
<bdi
|
||||
data-slot="session-timeline-notice-label"
|
||||
dir="auto"
|
||||
class="block truncate"
|
||||
classList={{ "shrink-0": !!props.value }}
|
||||
title={props.tooltip ? undefined : props.label}
|
||||
>
|
||||
{props.label}
|
||||
</bdi>
|
||||
)
|
||||
return (
|
||||
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
|
||||
<span class="h-px min-w-0 flex-1 bg-v2-border-border-strong" />
|
||||
@@ -10,9 +28,26 @@ export function TimelineSeparator(props: { label: string; providerID?: string; v
|
||||
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
|
||||
</Show>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<bdi dir="auto" class="truncate" title={props.label}>
|
||||
{props.label}
|
||||
</bdi>
|
||||
<Show when={props.tooltip} fallback={label()}>
|
||||
{(tooltip) => (
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
placement="top"
|
||||
value={tooltip()}
|
||||
class={props.value ? "min-w-0 shrink-0" : "min-w-0"}
|
||||
triggerTabIndex={0}
|
||||
>
|
||||
{label()}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.value}>
|
||||
{(value) => (
|
||||
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate" title={value()}>
|
||||
{value()}
|
||||
</bdi>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.variant && props.variant !== "default" ? props.variant : undefined}>
|
||||
{(variant) => (
|
||||
<>
|
||||
|
||||
@@ -32,7 +32,7 @@ type Data = {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
session_diff_preload?: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<unknown>[]
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<unknown, undefined>[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ export type HoverCommentLine = {
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export const LINE_COMMENT_ACTION_GAP = 8
|
||||
const LINE_COMMENT_ACTION_SIZE = 20
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
@@ -14,21 +17,22 @@ export function createHoverCommentUtility(props: {
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.width = `${LINE_COMMENT_ACTION_SIZE}px`
|
||||
button.style.height = `${LINE_COMMENT_ACTION_SIZE}px`
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.background = "var(--v2-background-bg-inverse)"
|
||||
button.style.color = "var(--v2-icon-icon-inverse)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.zIndex = "110"
|
||||
button.style.left = "-4px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
@@ -59,6 +63,29 @@ export function createHoverCommentUtility(props: {
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
const startLineSelection = (event: PointerEvent) => {
|
||||
const number = button.parentElement?.assignedSlot?.parentElement?.parentElement
|
||||
if (!(number instanceof HTMLElement)) return
|
||||
number.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
isPrimary: event.isPrimary,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
|
||||
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
|
||||
button.addEventListener("mouseenter", sync)
|
||||
@@ -67,6 +94,7 @@ export function createHoverCommentUtility(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
startLineSelection(event)
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -73,6 +73,19 @@ export function readShadowLineSelection(opts: {
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
const anchorTop = findElement(selection.anchorNode)
|
||||
?.closest("[data-line], [data-alt-line]")
|
||||
?.getBoundingClientRect().top
|
||||
const focusElement = findElement(selection.focusNode)
|
||||
const focusTop = focusElement?.closest("[data-line], [data-alt-line]")?.getBoundingClientRect().top
|
||||
const code = focusElement?.closest("[data-code]")
|
||||
const gutterRight = code?.firstElementChild?.getBoundingClientRect().right
|
||||
const direction =
|
||||
anchorTop === undefined || focusTop === undefined || anchorTop === focusTop
|
||||
? ("same" as const)
|
||||
: focusTop < anchorTop
|
||||
? ("up" as const)
|
||||
: ("down" as const)
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
@@ -81,5 +94,7 @@ export function readShadowLineSelection(opts: {
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
direction,
|
||||
gutterRight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRan
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T, undefined> & {
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
@@ -17,6 +17,11 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
const unsafeCSS = `
|
||||
:host {
|
||||
--diffs-bg: var(--opencode-diffs-bg, var(--color-background-stronger));
|
||||
/* Pierre mixes 15% in dark mode, which makes collapsed rows read as highlighted. */
|
||||
--diffs-bg-separator-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-mixer)),
|
||||
color-mix(in lab, var(--diffs-bg) 95%, var(--diffs-mixer))
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff],
|
||||
@@ -130,6 +135,12 @@ const unsafeCSS = `
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-gutter-utility-slot] {
|
||||
left: unset;
|
||||
right: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
@@ -138,15 +149,15 @@ const unsafeCSS = `
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* File annotations share the code column; reclaim the measured number gutter. */
|
||||
[data-file] [data-line-annotation] {
|
||||
margin-inline-start: calc(-1 * var(--diffs-column-number-width, 0px));
|
||||
z-index: 4;
|
||||
/* File annotations share the code column; reclaim the number gutter. Pierre only measures column widths for
|
||||
scrolling files with annotations, so derive the gutter from the code grid width instead. */
|
||||
[data-file][data-overflow='wrap'] [data-code] {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
[data-file] [data-annotation-content] {
|
||||
width: var(--diffs-column-width, auto);
|
||||
inset-inline-start: 0;
|
||||
[data-file][data-overflow='wrap'] [data-line-annotation] {
|
||||
margin-inline-start: calc(100% - 100cqi);
|
||||
z-index: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +209,7 @@ ${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T, undefined>["diffStyle"]) {
|
||||
return {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
@@ -210,7 +221,7 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"])
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
hunkSeparators: "line-info-basic",
|
||||
lineDiffType: "word-alt",
|
||||
lineDiffType: "word-line",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ export function workerFactory(): Worker {
|
||||
return new Worker(ShikiWorkerUrl, { type: "module" })
|
||||
}
|
||||
|
||||
function createPool(lineDiffType: "none" | "word-alt") {
|
||||
function createPool(lineDiffType: "none" | "word-line") {
|
||||
const pool = new WorkerPoolManager(
|
||||
{
|
||||
workerFactory,
|
||||
@@ -36,7 +36,7 @@ function createPool(lineDiffType: "none" | "word-alt") {
|
||||
let plain: WorkerPoolManager | undefined
|
||||
let diff: WorkerPoolManager | undefined
|
||||
|
||||
export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): WorkerPoolManager | undefined {
|
||||
export function getWorkerPool(lineDiffType: "none" | "word-line" = "word-line"): WorkerPoolManager | undefined {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
if (lineDiffType === "none") {
|
||||
@@ -44,7 +44,7 @@ export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): W
|
||||
return plain
|
||||
}
|
||||
|
||||
if (!diff) diff = createPool("word-alt")
|
||||
if (!diff) diff = createPool("word-line")
|
||||
return diff
|
||||
}
|
||||
|
||||
|
||||
@@ -271,7 +271,12 @@ export namespace Timeline {
|
||||
detail,
|
||||
new Set(
|
||||
messages
|
||||
.filter((message) => message.type === "compaction" || message.type === "model-switched")
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "compaction" ||
|
||||
message.type === "model-switched" ||
|
||||
message.type === "location-switched",
|
||||
)
|
||||
.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { timelinePresets } from "./detail"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
@@ -333,6 +334,39 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps divider notices outside grouped context rows", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "move", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [storyTool("tool_read", "read", "completed", {})],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{ id: "msg_moved", type: "location-switched", location: { directory: "/tmp/next" }, time: { created: 4 } },
|
||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 5 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
const rows = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
false,
|
||||
{ type: "idle" },
|
||||
undefined,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
timelinePresets.find((preset) => preset.id === "compact")!.value,
|
||||
).rows
|
||||
|
||||
expect(rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"assistant-part:context:context:msg_assistant:tool_read",
|
||||
"notice:msg_moved",
|
||||
"assistant-part:context:message:msg_skill",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps assistant errors and retries before later notices", () => {
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SessionStatus,
|
||||
} from "@opencode/client/promise"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
|
||||
@@ -522,25 +521,12 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
>
|
||||
{(message) => (
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
data-type="location-switched"
|
||||
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${inset()}`}
|
||||
>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
placement="top"
|
||||
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
|
||||
class="shrink-0"
|
||||
triggerTabIndex={0}
|
||||
>
|
||||
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
|
||||
{i18n.t("ui.sessionTimeline.notice.movedTo")}
|
||||
</bdi>
|
||||
</Tooltip>{" "}
|
||||
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
|
||||
{message().location.directory}
|
||||
</bdi>
|
||||
<div data-slot="session-timeline-notice" data-type="location-switched" class={`w-full py-2 ${inset()}`}>
|
||||
<TimelineSeparator
|
||||
label={i18n.t("ui.sessionTimeline.notice.movedTo")}
|
||||
value={message().location.directory}
|
||||
tooltip={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -455,7 +455,7 @@ const MovedLocation = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Moved Session location"
|
||||
description="A changed working directory stays compact, truncates, and exposes its tooltip."
|
||||
description="A changed working directory renders as a timeline divider, truncates, and exposes its tooltip."
|
||||
document={{
|
||||
...thinkingDocument,
|
||||
status: { type: "idle" },
|
||||
|
||||
@@ -228,6 +228,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
enableLineSelection={lineCommentsEnabled()}
|
||||
enableGutterUtility={lineCommentsEnabled()}
|
||||
textSelectionAction={lineCommentsEnabled() ? { label: i18n.t("ui.lineComment.add") } : undefined}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
if (!lineCommentsEnabled()) return
|
||||
commentsUi.onLineSelected(range)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.18",
|
||||
"version": "2.0.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -117,85 +117,61 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
height: 80px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
|
||||
resize: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
line-height: 1.35;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
scrollbar-width: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
|
||||
border-color: var(--v2-border-border-focus);
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding-block: 0 12px;
|
||||
padding-inline: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -61,7 +61,7 @@ export type LineCommentEditorMention = {
|
||||
}
|
||||
|
||||
export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
|
||||
/** Visible field label above the textarea (default: “Comment”). */
|
||||
/** Accessible editor label (default: “Comment”). */
|
||||
heading?: JSX.Element | string
|
||||
value: string
|
||||
onInput: (value: string) => void
|
||||
@@ -108,7 +108,6 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
"classList",
|
||||
])
|
||||
|
||||
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
|
||||
const canSubmit = () => local.value.trim().length > 0
|
||||
|
||||
const closeMention = () => {
|
||||
@@ -206,19 +205,19 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
>
|
||||
<div data-slot="line-comment-v2-shell">
|
||||
<div data-slot="line-comment-v2-field">
|
||||
<div data-slot="line-comment-v2-label">{heading()}</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef = el
|
||||
}}
|
||||
data-slot="line-comment-v2-textarea"
|
||||
aria-label={typeof local.heading === "string" ? local.heading : i18n.t("ui.lineComment.submit")}
|
||||
dir="auto"
|
||||
rows={local.rows ?? 3}
|
||||
placeholder={local.placeholder ?? i18n.t("ui.lineComment.contextPlaceholder")}
|
||||
value={local.value}
|
||||
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
|
||||
onInput={(e) => {
|
||||
local.onInput(e.currentTarget.value)
|
||||
onInput={(event) => {
|
||||
local.onInput(event.currentTarget.value)
|
||||
syncMention()
|
||||
}}
|
||||
onClick={() => syncMention()}
|
||||
@@ -292,12 +291,11 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-v2-footer">
|
||||
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
|
||||
<div data-slot="line-comment-v2-footer-actions">
|
||||
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
|
||||
<Button type="button" size="small" variant="ghost-muted" onClick={() => local.onCancel()}>
|
||||
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
|
||||
</Button>
|
||||
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
|
||||
<Button type="button" size="small" variant="submit" disabled={!canSubmit()} onClick={submit}>
|
||||
{local.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user