mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f479efa5f9 | ||
|
|
fbd9feda6a | ||
|
|
fc658f01ca | ||
|
|
3bb3de7be4 | ||
|
|
25c78e115f | ||
|
|
42dd7208cf | ||
|
|
2bf6a9813a | ||
|
|
f094a10645 | ||
|
|
89f1943d2d | ||
|
|
fc4f319676 | ||
|
|
b51fdb331c | ||
|
|
8a1a6b8fb4 | ||
|
|
1a2b985777 | ||
|
|
00067d23a0 | ||
|
|
3ebc4b01b9 | ||
|
|
17f362e0fe | ||
|
|
5b83c15fb5 | ||
|
|
d86c3041c9 | ||
|
|
3cf197a309 | ||
|
|
42bccc301e | ||
|
|
243e81041e | ||
|
|
5d971e5272 | ||
|
|
7ef9cb68d1 | ||
|
|
733f138dae | ||
|
|
0ff9ccb227 | ||
|
|
5ff8a3f5fa | ||
|
|
44513bd0b0 | ||
|
|
dc46ecfc55 |
@@ -63,6 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -357,6 +358,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -415,6 +417,7 @@
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
@@ -422,6 +425,8 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -432,12 +437,14 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "8.2.2",
|
||||
@@ -605,6 +612,21 @@
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode-ai/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode-ai/posts",
|
||||
"dependencies": {
|
||||
@@ -1824,6 +1846,16 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
@@ -2144,6 +2176,8 @@
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
@@ -2208,6 +2242,8 @@
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="],
|
||||
|
||||
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
||||
@@ -2218,6 +2254,8 @@
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="],
|
||||
@@ -2490,6 +2528,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="],
|
||||
|
||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="],
|
||||
|
||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
||||
@@ -2546,6 +2586,8 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
|
||||
@@ -2744,10 +2786,18 @@
|
||||
|
||||
"@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="],
|
||||
|
||||
"@sentry/node": ["@sentry/node@10.73.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/node-core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "@sentry/server-utils": "10.73.0", "import-in-the-middle": "^3.0.0" } }, "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg=="],
|
||||
|
||||
"@sentry/node-core": ["@sentry/node-core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g=="],
|
||||
|
||||
"@sentry/opentelemetry": ["@sentry/opentelemetry@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw=="],
|
||||
|
||||
"@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="],
|
||||
|
||||
"@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="],
|
||||
|
||||
"@sentry/server-utils": ["@sentry/server-utils@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" } }, "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA=="],
|
||||
|
||||
"@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="],
|
||||
|
||||
"@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="],
|
||||
@@ -3314,6 +3364,8 @@
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -3542,10 +3594,16 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
|
||||
|
||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
@@ -3602,6 +3660,8 @@
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
@@ -3638,6 +3698,8 @@
|
||||
|
||||
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
|
||||
|
||||
"csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
|
||||
@@ -3742,6 +3804,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
@@ -3790,6 +3854,8 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="],
|
||||
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="],
|
||||
@@ -3894,6 +3960,8 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
@@ -3938,7 +4006,7 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
@@ -4240,6 +4308,8 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
||||
@@ -4264,8 +4334,12 @@
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
"image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="],
|
||||
|
||||
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"import-in-the-middle": ["import-in-the-middle@3.4.0", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -4284,6 +4358,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||
|
||||
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -4402,12 +4478,16 @@
|
||||
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
|
||||
|
||||
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="],
|
||||
|
||||
"js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||
@@ -4480,8 +4560,16 @@
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="],
|
||||
|
||||
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||
|
||||
"lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="],
|
||||
|
||||
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
|
||||
|
||||
"lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
@@ -4528,6 +4616,8 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
@@ -4560,6 +4650,8 @@
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -4720,8 +4812,14 @@
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="],
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
@@ -5032,6 +5130,8 @@
|
||||
|
||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
@@ -5158,6 +5258,8 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||
|
||||
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
||||
|
||||
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||
@@ -5192,6 +5294,8 @@
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
|
||||
@@ -5350,6 +5454,8 @@
|
||||
|
||||
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
||||
|
||||
"speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
@@ -5484,6 +5590,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="],
|
||||
|
||||
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
@@ -5506,6 +5614,10 @@
|
||||
|
||||
"titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
|
||||
|
||||
"tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="],
|
||||
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||
@@ -5568,6 +5680,8 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
@@ -5728,6 +5842,8 @@
|
||||
|
||||
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
|
||||
|
||||
"web-features": ["web-features@3.37.0", "", {}, "sha512-47x5zchtpsvvH/EEQ0h3d3E9kZyFxFyk6LkmYHXDZjLF0RAa3OV2s8dMRqphp/PTxxrd/iZwz37dte6xWgV7mQ=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
@@ -5736,6 +5852,8 @@
|
||||
|
||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
@@ -5812,6 +5930,8 @@
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
@@ -6284,6 +6404,12 @@
|
||||
|
||||
"@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="],
|
||||
|
||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
@@ -6316,6 +6442,8 @@
|
||||
|
||||
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
@@ -6326,6 +6454,18 @@
|
||||
|
||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="],
|
||||
|
||||
"@sentry/node/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/node-core/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/server-utils/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
@@ -6428,6 +6568,10 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6446,6 +6590,8 @@
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
@@ -6490,6 +6636,8 @@
|
||||
|
||||
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6534,6 +6682,8 @@
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6542,10 +6692,16 @@
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
|
||||
|
||||
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -6610,6 +6766,10 @@
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="],
|
||||
|
||||
"puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6678,6 +6838,8 @@
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
||||
@@ -7194,6 +7356,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -7202,10 +7366,20 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
|
||||
@@ -7370,6 +7544,8 @@
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7406,6 +7582,8 @@
|
||||
|
||||
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
@@ -7428,6 +7606,12 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
@@ -8172,6 +8356,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
|
||||
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
|
||||
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
|
||||
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
|
||||
"x86_64-linux": "sha256-7NBjLAaZRbirLuBJdQO9iwlonqUFKOkU8u6rh/e8Ij0=",
|
||||
"aarch64-linux": "sha256-877mqEw+JGTTftYSWvHOsKnFRQ0B5umxY5xW31Rs+ms=",
|
||||
"aarch64-darwin": "sha256-eaWQZfyQMefy5kn+Q9dAzOnXcwjwx7EEtDuzpocgHOQ=",
|
||||
"x86_64-darwin": "sha256-mg+Sr8h7d2dmrnOfjiX/ktmA3wuBg/iGZv9ooFvlERc="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const route = Route.make({
|
||||
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
|
||||
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||
|
||||
@@ -115,6 +115,7 @@ Keep provider facades small and explicit:
|
||||
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
||||
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
|
||||
+28
-15
@@ -1,6 +1,6 @@
|
||||
# @opencode-ai/ai
|
||||
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
Schema-first language model and image-generation APIs built with Effect.
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
@@ -255,7 +255,7 @@ over the same implementation, including the legacy live `requests` array. New te
|
||||
|
||||
## Provider compaction
|
||||
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
|
||||
|
||||
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
|
||||
|
||||
@@ -298,7 +298,7 @@ result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
@@ -372,9 +372,7 @@ providerOptions: {
|
||||
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
|
||||
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
|
||||
|
||||
### Ownership and verification
|
||||
|
||||
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
|
||||
### Recording tests
|
||||
|
||||
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
|
||||
|
||||
@@ -393,7 +391,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tool definitions, system instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
|
||||
|
||||
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||
|
||||
@@ -461,22 +459,33 @@ const gateway = CloudflareAIGateway.configure({
|
||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||
Included LLM providers: OpenAI, Anthropic, Google (Gemini), Google Vertex, Amazon Bedrock, Azure OpenAI, Baseten, Cerebras, Cloudflare AI Gateway, Cloudflare Workers AI, DeepInfra, DeepSeek, Fireworks, Groq, Mistral, OpenRouter, TogetherAI, and xAI. Z.ai currently exposes image generation. Generic Chat Completions, Responses, and Anthropic Messages-compatible entrypoints support custom endpoints.
|
||||
|
||||
### Package-like entrypoints
|
||||
Each named provider owns its module, endpoint, authentication, and route setup. Providers with the same wire format compose the shared protocol directly:
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
|
||||
```ts
|
||||
import { DeepSeek, Fireworks } from "@opencode-ai/ai/providers"
|
||||
|
||||
const deepseek = DeepSeek.configure({ apiKey }).model("deepseek-chat")
|
||||
const fireworks = Fireworks.configure({ apiKey }).model("accounts/fireworks/models/my-model")
|
||||
```
|
||||
|
||||
The former `OpenAICompatible.baseten`, `.cerebras`, `.deepinfra`, `.deepseek`, `.fireworks`, `.groq`, and `.togetherai` presets are replaced by the top-level `Baseten`, `Cerebras`, `DeepInfra`, `DeepSeek`, `Fireworks`, `Groq`, and `TogetherAI` exports. Use `CloudflareAIGateway` and `CloudflareWorkersAI` directly; each has its own module. `OpenAICompatible` configures generic endpoints with an explicit `baseURL`.
|
||||
|
||||
### Provider entrypoints
|
||||
|
||||
Provider modules are available through dedicated exports from `@opencode-ai/ai`. Each LLM entrypoint exports `model(modelID, settings)`, where `settings` contains provider configuration plus common `headers` and `body` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
headers: { "x-application": "example" },
|
||||
})
|
||||
```
|
||||
|
||||
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
||||
APIs have separate entrypoints:
|
||||
|
||||
- `@opencode-ai/ai/providers/openai/chat`
|
||||
- `@opencode-ai/ai/providers/openai/responses`
|
||||
@@ -517,9 +526,13 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
Additional provider entrypoints include:
|
||||
|
||||
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
|
||||
- `@opencode-ai/ai/providers/baseten`
|
||||
- `@opencode-ai/ai/providers/deepseek`
|
||||
- `@opencode-ai/ai/providers/fireworks`
|
||||
- `@opencode-ai/ai/providers/cloudflare-ai-gateway`
|
||||
- `@opencode-ai/ai/providers/cloudflare-workers-ai`
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
@@ -546,7 +559,7 @@ LLM.request({
|
||||
|
||||
## Routes
|
||||
|
||||
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
|
||||
Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports receive the route's endpoint and auth when preparing requests. Unsupported request shapes fail during protocol lowering.
|
||||
|
||||
## Effect
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { AwsV4Signer } from "aws4fetch"
|
||||
import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import * as ProviderShared from "../src/protocols/shared"
|
||||
import * as Cloudflare from "../src/providers/cloudflare"
|
||||
import { CloudflareAIGateway } from "../src/providers/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
|
||||
|
||||
type Provider = {
|
||||
readonly id: string
|
||||
@@ -120,11 +121,11 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.aiGatewayBaseURL({
|
||||
url: `${CloudflareAIGateway.baseURL({
|
||||
accountId: env.CLOUDFLARE_ACCOUNT_ID,
|
||||
gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined,
|
||||
})}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)),
|
||||
token: Redacted.make(envValue(env, CloudflareAIGateway.authEnvVars)),
|
||||
tokenHeader: "cf-aig-authorization",
|
||||
model: "workers-ai/@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
@@ -140,8 +141,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
],
|
||||
validate: (env) =>
|
||||
validateChat({
|
||||
url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)),
|
||||
url: `${CloudflareWorkersAI.baseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`,
|
||||
token: Redacted.make(envValue(env, CloudflareWorkersAI.authEnvVars)),
|
||||
model: "@cf/meta/llama-3.1-8b-instruct",
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("baseten")
|
||||
const baseURL = "https://inference.baseten.co/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "baseten-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "baseten",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "BASETEN_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Baseten from "./baseten.js"
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cerebras")
|
||||
const baseURL = "https://api.cerebras.ai/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,19 +21,22 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "cerebras-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.cerebras.baseURL },
|
||||
providerMetadataKey: "cerebras",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Config, Redacted } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
export const authEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
|
||||
|
||||
type GatewayURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}> & {
|
||||
readonly gatewayId?: string
|
||||
}
|
||||
|
||||
export type LanguageModelOptions = GatewayURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
GatewayURL & {
|
||||
readonly apiKey?: string
|
||||
readonly gatewayApiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
const auth = (input: LanguageModelOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
|
||||
.orElse(Auth.config(authEnvVars[0]))
|
||||
.orElse(Auth.config(authEnvVars[1]))
|
||||
.pipe(Auth.bearerHeader("cf-aig-authorization"))
|
||||
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
|
||||
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
|
||||
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cloudflare-ai-gateway",
|
||||
provider: id,
|
||||
providerMetadataKey: "cloudflare-ai-gateway",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const {
|
||||
accountId: _accountId,
|
||||
gatewayId: _gatewayId,
|
||||
apiKey: _apiKey,
|
||||
gatewayApiKey: _gatewayApiKey,
|
||||
baseURL: _baseURL,
|
||||
auth: _auth,
|
||||
...defaults
|
||||
} = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: auth(input),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
gatewayApiKey: settings.gatewayApiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
export const authEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
|
||||
|
||||
type WorkersAIURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}>
|
||||
|
||||
export type LanguageModelOptions = WorkersAIURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
WorkersAIURL & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cloudflare-workers-ai",
|
||||
provider: id,
|
||||
providerMetadataKey: "cloudflare-workers-ai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions) => {
|
||||
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL(input) },
|
||||
auth: AuthOptions.bearer(input, authEnvVars),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Config, Redacted } from "effect"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
|
||||
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
|
||||
export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
|
||||
export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
|
||||
|
||||
type CloudflareSecret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
|
||||
type GatewayURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}> & {
|
||||
readonly gatewayId?: string
|
||||
}
|
||||
|
||||
export type AIGatewayOptions = GatewayURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: CloudflareSecret
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
type WorkersAIURL = AtLeastOne<{
|
||||
readonly accountId: string
|
||||
readonly baseURL: string
|
||||
}>
|
||||
|
||||
export type WorkersAIOptions = WorkersAIURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const aiGatewayBaseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
const aiGatewayAuth = (input: AIGatewayOptions) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
|
||||
.orElse(Auth.config("CLOUDFLARE_API_TOKEN"))
|
||||
.orElse(Auth.config("CF_AIG_TOKEN"))
|
||||
.pipe(Auth.bearerHeader("cf-aig-authorization"))
|
||||
if (!("apiKey" in input) || input.apiKey === undefined) return gateway
|
||||
if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey)
|
||||
return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey))
|
||||
}
|
||||
|
||||
export const workersAIBaseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
const workersAIAuth = (input: WorkersAIOptions) => {
|
||||
return AuthOptions.bearer(input, workersAIAuthEnvVars)
|
||||
}
|
||||
|
||||
export const aiGatewayRoute = OpenAICompatibleChat.route.with({
|
||||
id: "cloudflare-ai-gateway",
|
||||
provider: aiGatewayID,
|
||||
})
|
||||
|
||||
export const workersAIRoute = OpenAICompatibleChat.route.with({
|
||||
id: "cloudflare-workers-ai",
|
||||
provider: workersAIID,
|
||||
})
|
||||
|
||||
export const routes = [aiGatewayRoute, workersAIRoute]
|
||||
|
||||
const aiGatewayDefaults = (options: AIGatewayOptions) => {
|
||||
const {
|
||||
accountId: _accountId,
|
||||
gatewayId: _gatewayId,
|
||||
apiKey: _apiKey,
|
||||
gatewayApiKey: _gatewayApiKey,
|
||||
baseURL: _baseURL,
|
||||
auth: _auth,
|
||||
...rest
|
||||
} = options
|
||||
return rest
|
||||
}
|
||||
|
||||
const workersAIDefaults = (options: WorkersAIOptions) => {
|
||||
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options
|
||||
return rest
|
||||
}
|
||||
|
||||
const configureAIGateway = (options: AIGatewayOptions) => {
|
||||
const route = aiGatewayRoute.with({
|
||||
...aiGatewayDefaults(options),
|
||||
endpoint: { baseURL: aiGatewayBaseURL(options) },
|
||||
auth: aiGatewayAuth(options),
|
||||
})
|
||||
return {
|
||||
id: aiGatewayID,
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureAIGateway,
|
||||
}
|
||||
}
|
||||
|
||||
const configureWorkersAI = (options: WorkersAIOptions) => {
|
||||
const route = workersAIRoute.with({
|
||||
...workersAIDefaults(options),
|
||||
endpoint: { baseURL: workersAIBaseURL(options) },
|
||||
auth: workersAIAuth(options),
|
||||
})
|
||||
return {
|
||||
id: workersAIID,
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureWorkersAI,
|
||||
}
|
||||
}
|
||||
|
||||
export const CloudflareAIGateway = {
|
||||
id: aiGatewayID,
|
||||
configure: configureAIGateway,
|
||||
}
|
||||
|
||||
export const CloudflareWorkersAI = {
|
||||
id: workersAIID,
|
||||
configure: configureWorkersAI,
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepinfra")
|
||||
const baseURL = "https://api.deepinfra.com/v1/openai"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,21 +21,24 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "deepinfra-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.deepinfra.baseURL },
|
||||
providerMetadataKey: "deepinfra",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const root = baseURL?.replace(/\/+$/, "")
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const root = endpoint?.replace(/\/+$/, "")
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: {
|
||||
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
baseURL: root === undefined ? baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
|
||||
},
|
||||
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("deepseek")
|
||||
const baseURL = "https://api.deepseek.com/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepseek-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "deepseek",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "DEEPSEEK_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("fireworks")
|
||||
const baseURL = "https://api.fireworks.ai/inference/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "fireworks-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "fireworks",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "FIREWORKS_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
@@ -24,10 +25,13 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
const route = Route.make({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
@@ -24,11 +25,14 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
const route = Route.make({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerOptions: { store: false },
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("groq")
|
||||
const baseURL = "https://api.groq.com/openai/v1"
|
||||
|
||||
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
|
||||
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
|
||||
@@ -73,15 +73,15 @@ export const route = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -3,10 +3,13 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||
export * as AmazonBedrock from "./amazon-bedrock.js"
|
||||
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
|
||||
export * as Azure from "./azure.js"
|
||||
export * as Baseten from "./baseten.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cloudflare from "./cloudflare.js"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
export * as Google from "./google.js"
|
||||
export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export interface OpenAICompatibleProfile {
|
||||
readonly provider: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export const profiles = {
|
||||
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
|
||||
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
|
||||
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
|
||||
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
|
||||
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
|
||||
groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" },
|
||||
openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
|
||||
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
|
||||
xai: { provider: "xai", baseURL: "https://api.x.ai/v1" },
|
||||
} as const satisfies Record<string, OpenAICompatibleProfile>
|
||||
|
||||
export const byProvider: Record<string, OpenAICompatibleProfile> = Object.fromEntries(
|
||||
Object.values(profiles).map((profile) => [profile.provider, profile]),
|
||||
)
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
@@ -22,12 +21,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
export const configure = (input: GenericModelOptions) => {
|
||||
@@ -47,22 +40,6 @@ export const configure = (input: GenericModelOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
const define = (profile: OpenAICompatibleProfile) => {
|
||||
const configureProfile = (input: FamilyModelOptions = {}) => {
|
||||
const facade = configure({
|
||||
...input,
|
||||
baseURL: input.baseURL ?? profile.baseURL,
|
||||
provider: profile.provider,
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(profile.provider),
|
||||
model: facade.model,
|
||||
configure: configureProfile,
|
||||
}
|
||||
}
|
||||
return configureProfile()
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
@@ -78,10 +55,4 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
export const cerebras = define(profiles.cerebras)
|
||||
export const deepinfra = define(profiles.deepinfra)
|
||||
export const deepseek = define(profiles.deepseek)
|
||||
export const fireworks = define(profiles.fireworks)
|
||||
export const groq = define(profiles.groq)
|
||||
export const togetherai = define(profiles.togetherai)
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
export const id = ProviderID.make("openrouter")
|
||||
const baseURL = "https://openrouter.ai/api/v1"
|
||||
const ADAPTER = "openrouter"
|
||||
|
||||
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||
@@ -162,20 +161,20 @@ const bodyOptions = (input: unknown) => {
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: profile.provider,
|
||||
provider: id,
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? profile.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("togetherai")
|
||||
const baseURL = "https://api.together.xyz/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -20,19 +21,22 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
export const route = Route.make({
|
||||
id: "togetherai-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.togetherai.baseURL },
|
||||
providerMetadataKey: "togetherai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
@@ -12,6 +10,7 @@ import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
const baseURL = "https://api.x.ai/v1"
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
|
||||
@@ -37,7 +36,7 @@ const responsesRoute = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
endpoint: Endpoint.path("/responses", { baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
@@ -51,8 +50,8 @@ const chatRoute = Route.make({
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
headers: ({ request }): Record<string, string> =>
|
||||
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
|
||||
})
|
||||
@@ -62,19 +61,19 @@ export const routes = [responsesRoute, chatRoute]
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||
|
||||
const configuredResponsesRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return responsesRoute.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
}
|
||||
|
||||
const configuredChatRoute = (input: LanguageModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
|
||||
return chatRoute.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
}
|
||||
@@ -88,7 +87,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface WebSocketChannelExchange {
|
||||
readonly connect: {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
/** Provider-safe connection age after which Core should rotate before sending. */
|
||||
/** Provider-safe connection age after which the channel executor should reconnect before sending. */
|
||||
readonly rotateAfterMs?: number
|
||||
}
|
||||
readonly fallback: () => Stream.Stream<string, AIError>
|
||||
|
||||
@@ -6,7 +6,8 @@ import * as AmazonBedrock from "../src/providers/amazon-bedrock.js"
|
||||
import * as Anthropic from "../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../src/providers/anthropic-compatible.js"
|
||||
import * as Azure from "../src/providers/azure.js"
|
||||
import * as Cloudflare from "../src/providers/cloudflare.js"
|
||||
import { CloudflareWorkersAI } from "../src/providers/cloudflare-workers-ai.js"
|
||||
import { DeepSeek } from "../src/providers/deepseek.js"
|
||||
import * as Google from "../src/providers/google.js"
|
||||
import * as GoogleVertex from "../src/providers/google-vertex.js"
|
||||
import * as GoogleVertexChat from "../src/providers/google-vertex-chat.js"
|
||||
@@ -263,10 +264,10 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
|
||||
// @ts-expect-error xAI Chat selectors only accept model ids.
|
||||
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
|
||||
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
||||
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
||||
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
||||
DeepSeek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
||||
|
||||
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
|
||||
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
|
||||
CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama", {})
|
||||
|
||||
@@ -3,8 +3,11 @@ import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@o
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||
import {
|
||||
Baseten,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
@@ -48,7 +51,13 @@ describe("public exports", () => {
|
||||
expect(OpenAI.model).toBeFunction()
|
||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
for (const provider of [Baseten, DeepSeek, Fireworks]) {
|
||||
expect(provider.configure).toBeFunction()
|
||||
expect(provider.model).toBeFunction()
|
||||
}
|
||||
for (const name of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks", "groq", "togetherai"]) {
|
||||
expect(OpenAICompatible).not.toHaveProperty(name)
|
||||
}
|
||||
expect(
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://responses.test/v1" }).model("fixture").route.id,
|
||||
).toBe("openai-compatible-responses")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { OpenAICompatible } from "../../src/providers.js"
|
||||
|
||||
const model = OpenAICompatible.deepseek.model("deepseek-chat")
|
||||
const model = OpenAICompatible.configure({ baseURL: "https://compatible.example/v1" }).model("test-model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { store: false } })
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
import("@opencode-ai/ai/providers/groq"),
|
||||
import("@opencode-ai/ai/providers/baseten"),
|
||||
import("@opencode-ai/ai/providers/deepseek"),
|
||||
import("@opencode-ai/ai/providers/fireworks"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -60,6 +65,27 @@ describe("provider package entrypoints", () => {
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
|
||||
test("maps Cloudflare package settings onto provider-owned models", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
|
||||
])
|
||||
for (const provider of modules) {
|
||||
const selected = provider.model("provider-model", {
|
||||
accountId: "account",
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { custom: true },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ custom: true })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import { CloudflareAIGateway } from "../../src/providers/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAI } from "../../src/providers/cloudflare-workers-ai.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import {
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
DeepSeek,
|
||||
TogetherAI,
|
||||
} from "../../src/providers/index.js"
|
||||
import * as Google from "../../src/providers/google.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
@@ -45,16 +51,18 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
|
||||
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
|
||||
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||
const deepseek = OpenAICompatible.deepseek
|
||||
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
|
||||
.model("deepseek-chat")
|
||||
const deepseek = DeepSeek.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }).model("deepseek-chat")
|
||||
const together = TogetherAI.configure({
|
||||
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
// These older cassettes exercise generic Chat compatibility. Native Groq request
|
||||
// shaping and reasoning are covered by groq.recorded.test.ts.
|
||||
const groq = OpenAICompatible.configure({
|
||||
provider: "groq",
|
||||
baseURL: "https://api.groq.com/openai/v1",
|
||||
apiKey: process.env.GROQ_API_KEY ?? "fixture",
|
||||
}).model("llama-3.3-70b-versatile")
|
||||
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
)
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
Anthropic,
|
||||
AnthropicCompatible,
|
||||
Azure,
|
||||
Baseten,
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
Google,
|
||||
GoogleVertex,
|
||||
GoogleVertexChat,
|
||||
@@ -57,6 +60,9 @@ describe("native OpenAI-compatible providers", () => {
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[Baseten.configure({ apiKey: "test" }).model("model"), "baseten"],
|
||||
[DeepSeek.configure({ apiKey: "test" }).model("model"), "deepseek"],
|
||||
[Fireworks.configure({ apiKey: "test" }).model("model"), "fireworks"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
@@ -87,6 +93,36 @@ describe("native OpenAI-compatible providers", () => {
|
||||
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
|
||||
})
|
||||
|
||||
it.effect("preserves extracted providers' Chat requests and identity through custom endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const provider of [Baseten, DeepSeek, Fireworks]) {
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
}
|
||||
const selected = provider.configure(settings).model("test-model")
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.id).toBe(`${provider.id}-chat`)
|
||||
expect(selected.route.protocol).toBe("openai-chat")
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
const input = {
|
||||
prompt: "Use a tool.",
|
||||
generation: { maxTokens: 48 },
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}
|
||||
const native = yield* compileRequest(LLM.request({ ...input, model: selected }))
|
||||
const generic = yield* compileRequest(
|
||||
LLM.request({
|
||||
...input,
|
||||
model: OpenAICompatible.configure({ ...settings, provider: provider.id }).model("test-model"),
|
||||
}),
|
||||
)
|
||||
expect(native.body).toEqual(generic.body)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
test("preserves native DeepInfra provider and route identity", () => {
|
||||
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
|
||||
expect(deepinfra).toMatchObject({
|
||||
@@ -164,7 +200,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
})
|
||||
|
||||
test("maps package settings onto native executable models", () => {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
for (const native of [Baseten, Cerebras, DeepSeek, Fireworks, TogetherAI]) {
|
||||
const selected = native.model("provider-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
@@ -183,6 +219,24 @@ describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const scenarios = [
|
||||
{
|
||||
model: Baseten.configure().model("model"),
|
||||
env: { BASETEN_API_KEY: "baseten-secret" },
|
||||
token: "baseten-secret",
|
||||
url: "https://inference.baseten.co/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: DeepSeek.configure().model("deepseek-chat"),
|
||||
env: { DEEPSEEK_API_KEY: "deepseek-secret" },
|
||||
token: "deepseek-secret",
|
||||
url: "https://api.deepseek.com/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Fireworks.configure().model("model"),
|
||||
env: { FIREWORKS_API_KEY: "fireworks-secret" },
|
||||
token: "fireworks-secret",
|
||||
url: "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: TogetherAI.configure().model("llama"),
|
||||
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
|
||||
|
||||
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
@@ -41,15 +40,6 @@ const usageChunk = (usage: object) => ({
|
||||
usage,
|
||||
})
|
||||
|
||||
const providerFamilies = [
|
||||
["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
|
||||
["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
|
||||
["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
|
||||
["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
|
||||
["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
|
||||
["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
|
||||
] as const
|
||||
|
||||
describe("OpenAI-compatible Chat route", () => {
|
||||
it.effect("prepares generic Chat target", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -91,39 +81,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
test("provides model helpers for compatible provider families", () => {
|
||||
expect(
|
||||
providerFamilies.map(([provider, family]) => {
|
||||
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
|
||||
return {
|
||||
id: String(model.id),
|
||||
provider: String(model.provider),
|
||||
route: model.route.id,
|
||||
baseURL: model.route.endpoint.baseURL,
|
||||
}
|
||||
}),
|
||||
).toEqual(
|
||||
providerFamilies.map(([provider, _, baseURL]) => ({
|
||||
id: `${provider}-model`,
|
||||
provider,
|
||||
route: "openai-compatible-chat",
|
||||
baseURL,
|
||||
})),
|
||||
)
|
||||
|
||||
const custom = OpenAICompatible.deepseek
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.deepseek.test/v1",
|
||||
})
|
||||
.model("deepseek-chat")
|
||||
expect(custom).toMatchObject({
|
||||
provider: "deepseek",
|
||||
route: { id: "openai-compatible-chat" },
|
||||
})
|
||||
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
|
||||
})
|
||||
|
||||
it.effect("matches AI SDK compatible basic request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
@@ -34,7 +34,7 @@ const observationFrame = (observation: ChannelObservation) => {
|
||||
|
||||
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
|
||||
// This channel fixture supports sequential test traffic.
|
||||
const makeChannel = Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
let connection: WebSocketConnection | undefined
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("maps grouped and collapsed switches to the timeline settings", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const grouped = shell.getByRole("switch", { name: "Shell grouped", exact: true })
|
||||
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
|
||||
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
|
||||
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
|
||||
await expect(grouped).toBeChecked()
|
||||
await expect(collapsed).toBeChecked()
|
||||
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
|
||||
await expect(value).toContainText('"shell":{"placement":"separate","details":"expanded"}')
|
||||
await grouped.focus()
|
||||
await grouped.press("Space")
|
||||
await collapsed.focus()
|
||||
await collapsed.press("Space")
|
||||
await expect(value).toContainText('"shell":{"placement":"grouped","details":"collapsed"}')
|
||||
await expect(component.getByRole("group", { name: "Subagents", exact: true }).getByRole("switch")).toHaveCount(1)
|
||||
})
|
||||
|
||||
story("replaces hidden switches with solid lines and restores options", async ({ mount, page }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
const color = await label.evaluate((element) => getComputedStyle(element).color)
|
||||
const iconColor = await visibility.evaluate((element) => getComputedStyle(element).color)
|
||||
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
|
||||
await visibility.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Hide")
|
||||
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
|
||||
await visibility.evaluate((element) => element.getBoundingClientRect().top),
|
||||
)
|
||||
await visibility.click()
|
||||
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(shell.getByRole("switch")).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
|
||||
"border-top-style",
|
||||
"solid",
|
||||
)
|
||||
await expect(component.locator('[data-slot="timeline-detail-fixture-value"]')).toContainText(
|
||||
'"shell":{"placement":"hidden","details":"expanded"}',
|
||||
)
|
||||
await expect(label).not.toHaveCSS("color", color)
|
||||
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye-slash")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).hover()
|
||||
await expect(visibility).not.toHaveCSS("color", iconColor)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
|
||||
"border-top-color",
|
||||
await visibility.evaluate((element) => getComputedStyle(element).color),
|
||||
)
|
||||
await visibility.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Show")
|
||||
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
|
||||
await visibility.evaluate((element) => element.getBoundingClientRect().top),
|
||||
)
|
||||
await visibility.click()
|
||||
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(label).toHaveCSS("color", color)
|
||||
await expect(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye")
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeEnabled()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeEnabled()
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("toggles visibility by clicking the activity label", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
|
||||
await expect(label).toHaveCSS("cursor", "default")
|
||||
await label.click()
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(shell.getByRole("switch")).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
|
||||
await label.click()
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
|
||||
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeChecked()
|
||||
})
|
||||
|
||||
story("only highlights the eye when hovering the icon, not its activity label", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const shell = component.getByRole("group", { name: "Shell", exact: true })
|
||||
const visibility = shell.getByRole("button", { name: "Shell visibility" })
|
||||
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
|
||||
|
||||
for (const hidden of [false, true]) {
|
||||
if (hidden) await label.click()
|
||||
await visibility.hover()
|
||||
await expect(visibility).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
await label.hover()
|
||||
await expect(visibility).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
|
||||
}
|
||||
})
|
||||
|
||||
story("opens advanced on returning to custom settings but not presets", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
const advanced = component.getByRole("button", { name: "Advanced", exact: true })
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
await advanced.click()
|
||||
await component.locator('[data-category="shell"][data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await advanced.click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
await component.getByRole("button", { name: "Leave settings" }).click()
|
||||
await expect(advanced).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Return to settings" }).click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
|
||||
|
||||
await component.getByRole("slider").press("End")
|
||||
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Everything")
|
||||
await component.getByRole("button", { name: "Leave settings" }).click()
|
||||
await component.getByRole("button", { name: "Return to settings" }).click()
|
||||
await expect(advanced).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
story("keeps visibility and switches in sync with the preset slider", async ({ mount }) => {
|
||||
const component = await mount("settings-timeline-detail--interactive")
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const slider = component.getByRole("slider", { name: "Timeline detail" })
|
||||
await slider.focus()
|
||||
await slider.press("Home")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
|
||||
await expect(component.getByRole("switch")).toHaveCount(0)
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
|
||||
await component.getByRole("button", { name: "Shell visibility" }).click()
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await slider.focus()
|
||||
await slider.press("End")
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
|
||||
await expect(component.getByRole("switch")).toHaveCount(9)
|
||||
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
|
||||
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
|
||||
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
for (const theme of ["light", "dark"]) {
|
||||
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 })
|
||||
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
|
||||
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
|
||||
"color",
|
||||
await component
|
||||
.locator('[data-slot="settings-row-title"]')
|
||||
.evaluate((element) => getComputedStyle(element).color),
|
||||
)
|
||||
await component.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
const track = component.locator('[data-slot="timeline-detail-track"]')
|
||||
await expect(track).toHaveCSS(
|
||||
"--timeline-detail-track-background",
|
||||
await track.evaluate(
|
||||
(element, theme) =>
|
||||
getComputedStyle(element)
|
||||
.getPropertyValue(theme === "light" ? "--v2-background-bg-layer-04" : "--v2-background-bg-layer-03")
|
||||
.trim(),
|
||||
theme,
|
||||
),
|
||||
)
|
||||
if (theme === "light") {
|
||||
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
|
||||
await expect(track).toHaveCSS(
|
||||
"--timeline-detail-marker-background",
|
||||
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
|
||||
)
|
||||
}
|
||||
if (theme === "dark") {
|
||||
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
|
||||
const list = component.locator('[data-slot="timeline-detail-list"]')
|
||||
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
|
||||
for (const [column, field] of [
|
||||
[2, "placement"],
|
||||
[3, "details"],
|
||||
] as const) {
|
||||
const heading = await component
|
||||
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
const toggle = await component
|
||||
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.x + rect.width / 2
|
||||
})
|
||||
expect(Math.abs(heading - toggle)).toBeLessThan(1)
|
||||
}
|
||||
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
|
||||
for (const width of [900, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
|
||||
expect(await list.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const visibility = component.getByRole("button", { name: "Shell visibility" })
|
||||
await visibility.focus()
|
||||
await visibility.press("Space")
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toHaveCount(0)
|
||||
await expect(
|
||||
component
|
||||
.getByRole("group", { name: "Shell", exact: true })
|
||||
.locator('[data-slot="timeline-detail-unavailable"]'),
|
||||
).toHaveCount(2)
|
||||
await visibility.press("Space")
|
||||
await expect(visibility).toHaveAttribute("aria-pressed", "true")
|
||||
|
||||
const slider = component.getByRole("slider", { name: "Timeline detail" })
|
||||
const track = component.locator('[data-slot="timeline-detail-track"]')
|
||||
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
|
||||
await component
|
||||
.locator('[data-slot="timeline-detail-scale"]')
|
||||
.evaluate((element) => element.getBoundingClientRect().width),
|
||||
)
|
||||
await slider.focus()
|
||||
await slider.press("Home")
|
||||
for (const position of [0, 1, 2, 3, 4]) {
|
||||
if (position > 0) await slider.press("ArrowUp")
|
||||
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
|
||||
const fill = await track.evaluate((element) => {
|
||||
const style = getComputedStyle(element, "::before")
|
||||
return {
|
||||
fraction: parseFloat(style.width) / element.getBoundingClientRect().width,
|
||||
start: style.getPropertyValue("inset-inline-start"),
|
||||
color: style.backgroundColor,
|
||||
remainder: getComputedStyle(element).backgroundColor,
|
||||
}
|
||||
})
|
||||
expect(fill.fraction).toBeCloseTo(position / 4, 2)
|
||||
expect(fill.start).toBe("0px")
|
||||
expect(fill.color).not.toBe(fill.remainder)
|
||||
const marker = await track.locator("span").nth(position).boundingBox()
|
||||
const bounds = await track.boundingBox()
|
||||
expect(marker).not.toBeNull()
|
||||
expect(bounds).not.toBeNull()
|
||||
expect((marker!.x + marker!.width / 2 - bounds!.x) / bounds!.width).toBeCloseTo(
|
||||
direction === "rtl" ? 1 - position / 4 : position / 4,
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,21 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(slider).toBeEnabled()
|
||||
await slider.press("Home")
|
||||
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
|
||||
for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) {
|
||||
if (index) await slider.press("ArrowRight")
|
||||
await expect(slider).toHaveValue(String(index))
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", name)
|
||||
}
|
||||
await slider.press("Home")
|
||||
await slider.press("End")
|
||||
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Grouped", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
|
||||
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
|
||||
const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true })
|
||||
const collapsed = settings.getByRole("switch", { name: "Thinking collapsed", exact: true })
|
||||
await expect(grouped).not.toBeChecked()
|
||||
await expect(collapsed).not.toBeChecked()
|
||||
await settings.locator('[data-category="thinking"][data-field="placement"] [data-slot="switch-control"]').click()
|
||||
await settings.locator('[data-category="thinking"][data-field="details"] [data-slot="switch-control"]').click()
|
||||
await expect(grouped).toBeChecked()
|
||||
await expect(collapsed).toBeChecked()
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -33,7 +33,7 @@ for (const viewport of [
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(settings).toBeFocused()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wheel over the outer gutter must not move the entire settings screen.
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -4,8 +4,20 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
export { flushPersisted } from "./runtime/persistence/persist"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
|
||||
import { KeyboardInsets } from "@/runtime/platform/keyboard"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
@@ -92,6 +93,7 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
>
|
||||
<KeyboardInsets />
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
</AppBaseProviders>
|
||||
|
||||
@@ -75,9 +75,14 @@ export function createNewSessionWorkspaceController(input: {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
|
||||
// behaves like a plain read, which holds the transition that opens the New Session tab until
|
||||
// the worktree list returns.
|
||||
const worktreesLoaded = () => worktrees.state === "ready" || worktrees.state === "refreshing"
|
||||
const worktreeItems = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
if (!worktreesLoaded()) return project.worktrees
|
||||
const loaded = worktrees.latest
|
||||
return loaded?.projectID === project.id ? loaded.items : project.worktrees
|
||||
})
|
||||
@@ -110,10 +115,11 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = currentProject()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, worktree))
|
||||
? worktree
|
||||
: undefined
|
||||
if (isWorkspaceSelection(project, worktree)) return worktree
|
||||
// A saved choice may only exist in the server inventory. Keep it until the list can confirm it,
|
||||
// otherwise the selector falls back to Local while loading and a submit would target the wrong directory.
|
||||
if (!worktreesLoaded()) return worktree
|
||||
return worktreeDirectories().some((item) => sameDirectory(item, worktree)) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = currentProject()
|
||||
@@ -140,12 +146,19 @@ export function createNewSessionWorkspaceController(input: {
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
createEffect(() => {
|
||||
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
|
||||
() => undefined,
|
||||
)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
void Promise.all([
|
||||
data.location.syncInfo({ directory: sdk().directory }),
|
||||
data.project.sync(),
|
||||
data.location.vcs.sync({ directory: sdk().directory }),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
// Only the selected worktree feeds the branch label. Syncing every worktree in the inventory boots
|
||||
// each one on the server, which then emits `agent.updated` and makes the client run the full
|
||||
// catalog fan-out for every directory.
|
||||
createEffect(() => {
|
||||
const selection = value()
|
||||
if (selection === "main" || selection === "create") return
|
||||
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
@@ -169,12 +182,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
workspace: createMemo(() => {
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return (
|
||||
current === "create" ||
|
||||
(!!project &&
|
||||
(isWorkspaceDirectory(project, current) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, current))))
|
||||
)
|
||||
if (current === "create") return true
|
||||
if (current === "main" || !project) return false
|
||||
if (isWorkspaceDirectory(project, current) || !worktreesLoaded()) return true
|
||||
return worktreeDirectories().some((item) => sameDirectory(item, current))
|
||||
}),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
|
||||
@@ -667,6 +667,8 @@ export const dict = {
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.browser": "Browser",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
@@ -823,6 +825,10 @@ export const dict = {
|
||||
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
@@ -994,37 +1000,36 @@ export const dict = {
|
||||
|
||||
"settings.timeline.title": "Timeline",
|
||||
"settings.timeline.detail": "Timeline detail",
|
||||
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
|
||||
"settings.timeline.description": "Choose how much detail appears in the session timeline.",
|
||||
"settings.timeline.summary": "{{preset}}:",
|
||||
"settings.timeline.preset.everything": "Everything",
|
||||
"settings.timeline.preset.detailed": "Detailed",
|
||||
"settings.timeline.preset.compact": "Compact",
|
||||
"settings.timeline.preset.quiet": "Quiet",
|
||||
"settings.timeline.preset.text-only": "Text only",
|
||||
"settings.timeline.preset.text-only": "Messages only",
|
||||
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
|
||||
"settings.timeline.description.detailed":
|
||||
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
|
||||
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
|
||||
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
|
||||
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
|
||||
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
|
||||
"Expand shell output and edits. Show subagents separately and group other activity.",
|
||||
"settings.timeline.description.compact": "Group all activity with details collapsed.",
|
||||
"settings.timeline.description.quiet": "Group edits and subagents. Hide other activity.",
|
||||
"settings.timeline.description.text-only": "Hide all activity.",
|
||||
"settings.timeline.description.custom": "Uses advanced settings.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
|
||||
"settings.timeline.activity": "Activity",
|
||||
"settings.timeline.group": "Group",
|
||||
"settings.timeline.collapse": "Collapse",
|
||||
"settings.timeline.visibility.show": "Show",
|
||||
"settings.timeline.visibility.hide": "Hide",
|
||||
"settings.timeline.visibility.label": "{{activity}} visibility",
|
||||
"settings.timeline.grouped.label": "{{activity}} grouped",
|
||||
"settings.timeline.collapsed.label": "{{activity}} collapsed",
|
||||
"settings.timeline.category.shell": "Shell",
|
||||
"settings.timeline.category.edit": "Edits",
|
||||
"settings.timeline.category.thinking": "Thinking",
|
||||
"settings.timeline.category.subagents": "Subagents",
|
||||
"settings.timeline.category.notices": "Notices",
|
||||
"settings.timeline.category.tools": "Other tools",
|
||||
"settings.timeline.placement.title": "Placement",
|
||||
"settings.timeline.placement.separate": "Separate",
|
||||
"settings.timeline.placement.grouped": "Grouped",
|
||||
"settings.timeline.placement.hidden": "Hidden",
|
||||
"settings.timeline.expansion.title": "Details",
|
||||
"settings.timeline.expansion.collapsed": "Collapsed",
|
||||
"settings.timeline.expansion.expanded": "Expanded",
|
||||
|
||||
"settings.general.row.language.title": "Language",
|
||||
"settings.general.row.language.description": "Change the display language for OpenCode",
|
||||
@@ -1050,6 +1055,8 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
|
||||
@@ -49,6 +49,36 @@ migration rules remain explicit in their schemas.
|
||||
invalid entries individually. Valid entries still pass through their codecs.
|
||||
- Recovery is not a substitute for an explicit historical shape transformation.
|
||||
|
||||
## Writes
|
||||
|
||||
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
|
||||
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
|
||||
hides, and the write is skipped when the serialized form did not change. Reactive observers
|
||||
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
|
||||
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
|
||||
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
|
||||
change wins over a value arriving from another window, and over a stored value that finishes
|
||||
loading after the user already edited. A remote value that arrives while the store is dirty
|
||||
is held until the save runs; if the local setter calls turned out not to change the
|
||||
serialized form, the remote value is adopted instead of being lost.
|
||||
|
||||
## Namespaces
|
||||
|
||||
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
|
||||
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
|
||||
namespace is loaded from the host once, reads are Map lookups from then on, and writes
|
||||
update the cache immediately while being batched into one host round trip per flush
|
||||
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
|
||||
flush on page hide is on the wire before the page goes away; the desktop platform flushes
|
||||
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
|
||||
local write carries a sequence number that is kept until the host accepts that exact write;
|
||||
until then neither the initial load, a change from another window (`accept`), nor the retry
|
||||
of an older failed batch can replace the key. The host also stamps every update with a
|
||||
monotonic revision, returned in the ack and carried by change events and loads, so an event
|
||||
that reaches a window after a newer ack or load for the same key is recognised as stale and
|
||||
dropped; an event held back during an in-flight write is applied after the ack when the host
|
||||
ordered it later.
|
||||
|
||||
## Migrations
|
||||
|
||||
Describe shipped representations with schemas and transform their typed values
|
||||
@@ -78,3 +108,28 @@ Storage-key relocation (`previousKey`, workspace aliases, draft storage moves)
|
||||
remains separate from schema migration. Draft blob externalization and hydration
|
||||
also remain in the storage adapter: composer codecs receive hydrated references,
|
||||
not raw ID-only blob documents.
|
||||
|
||||
## Large draft content
|
||||
|
||||
Draft documents never carry large text inline. Any string of `draftTextThreshold`
|
||||
characters or more is split into `draftTextChunk`-sized content-addressed blobs and
|
||||
stored as `{ blob: { kind: "text", ids: [...] } }`; reads join the chunks again. A
|
||||
content-keyed cache means unchanged chunks are not hashed or sent on later saves, so
|
||||
typing after a large paste uploads one chunk per save rather than the paste. Chunk
|
||||
boundaries never split a surrogate pair, and a failed upload is evicted so the next save
|
||||
retries it.
|
||||
|
||||
Blob collection is made safe by validation on write, not by timing. A strict document write
|
||||
is refused while it references blob ids the store does not hold, so the previous document
|
||||
stays visible; the renderer uploads the missing bytes again from the chunk text or the image
|
||||
`Blob` it still holds, renames the references to the ids the uploads returned (content hashes
|
||||
normally, fresh ids on a store without WebCrypto), and publishes. This covers a chunk another
|
||||
tab collected, and an image the composer kept in its history long after its blob was
|
||||
collected. The desktop host additionally refreshes `touched_at` for every blob a written
|
||||
document references and collects only blobs unreferenced and untouched for `blobGrace`, so
|
||||
repairs stay rare.
|
||||
`persisted()` hands the draft store the encoded document (`setDocument`) rather than
|
||||
a serialized string, so the store does not re-parse the full document to externalize
|
||||
it. Both blob collectors (desktop SQL, browser IndexedDB) keep chunk ids alive. This
|
||||
follows VS Code's rule that editor content lives in per-resource backups, not in the
|
||||
state database.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
let puts = 0
|
||||
return {
|
||||
documents,
|
||||
blobs,
|
||||
puts: () => puts,
|
||||
driver: {
|
||||
get: async (key: string) => documents.get(key) ?? null,
|
||||
// Like the real stores: report referenced blobs that are not held; a strict write with any
|
||||
// missing is refused.
|
||||
set: async (key: string, value: string, strict: boolean) => {
|
||||
const ids = new Set<string>()
|
||||
JSON.parse(value, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
|
||||
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
|
||||
return item
|
||||
})
|
||||
const missing = [...ids].filter((id) => !blobs.has(id))
|
||||
if (!strict || missing.length === 0) documents.set(key, value)
|
||||
return missing
|
||||
},
|
||||
remove: async (key: string) => void documents.delete(key),
|
||||
putBlob: async (blob: Blob) => {
|
||||
puts++
|
||||
const id = `blob-${await blob.text().then((text) => Bun.hash(text).toString(16))}`
|
||||
blobs.set(id, blob)
|
||||
return id
|
||||
},
|
||||
getBlob: async (id: string) => blobs.get(id) ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const large = "x".repeat(draftTextThreshold)
|
||||
const paste = Array.from({ length: 3 * draftTextChunk }, (_, i) => String.fromCharCode(97 + (i % 26))).join("")
|
||||
|
||||
describe("draft store text externalization", () => {
|
||||
test("large strings become chunk lists and small ones stay inline", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "text", content: "hi" },
|
||||
],
|
||||
})
|
||||
const stored = JSON.parse(memory.documents.get("doc")!)
|
||||
expect(stored.prompt[0].content.blob.kind).toBe("text")
|
||||
expect(stored.prompt[0].content.blob.ids).toHaveLength(3)
|
||||
expect(stored.prompt[1].content).toBe("hi")
|
||||
expect(memory.documents.get("doc")!.length).toBeLessThan(400)
|
||||
const chunks = await Promise.all(
|
||||
stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()),
|
||||
)
|
||||
expect(chunks.join("")).toBe(paste)
|
||||
})
|
||||
|
||||
test("appending to a large string re-uploads only the final chunk", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
|
||||
expect(memory.puts()).toBe(3)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
|
||||
expect(memory.puts()).toBe(4)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }], cursor: 1 })
|
||||
expect(memory.puts()).toBe(4)
|
||||
})
|
||||
|
||||
test("reads join the chunks again and reuse cached content", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
|
||||
memory.blobs.clear()
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: paste }] })
|
||||
})
|
||||
|
||||
test("a missing chunk decodes to empty text instead of failing the document", async () => {
|
||||
const memory = memoryDriver()
|
||||
memory.documents.set(
|
||||
"doc",
|
||||
JSON.stringify({ prompt: [{ type: "text", content: { blob: { kind: "text", ids: ["gone"] } } }] }),
|
||||
)
|
||||
const store = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await store.getItem("doc"))!)).toEqual({ prompt: [{ type: "text", content: "" }] })
|
||||
})
|
||||
|
||||
test("a cached chunk id the store no longer holds is uploaded again on the next save", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
const [id] = JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
|
||||
// Another tab collected the chunk for `large` while this tab still caches its id.
|
||||
memory.blobs.clear()
|
||||
// Undo republishes the cached id; the write reports it missing and the chunk is uploaded again.
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
expect(memory.puts()).toBe(3)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
|
||||
expect(memory.blobs.has(id)).toBe(true)
|
||||
})
|
||||
|
||||
test("an image reference whose blob was collected is restored from its object url", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
const image = await store.putBlob(new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }))
|
||||
// The composer kept this reference (for example in its history) while the store collected the bytes.
|
||||
memory.blobs.clear()
|
||||
await store.setDocument("doc", { prompt: [{ type: "image", blob: { id: image.id, url: image.url } }] })
|
||||
expect(memory.blobs.has(image.id)).toBe(true)
|
||||
expect(new Uint8Array(await memory.blobs.get(image.id)!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]))
|
||||
})
|
||||
|
||||
test("the previous document stays visible until missing blobs are restored", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${large}!` }] })
|
||||
const before = memory.documents.get("doc")
|
||||
memory.blobs.clear()
|
||||
// Hold the repair upload: while it is pending, another reader must still see the old document.
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const putBlob = memory.driver.putBlob
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
await gate.promise
|
||||
return putBlob(blob)
|
||||
}
|
||||
const saving = store.setDocument("doc", { prompt: [{ type: "text", content: large }] })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(memory.documents.get("doc")).toBe(before)
|
||||
gate.resolve()
|
||||
await saving
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(large)
|
||||
})
|
||||
|
||||
test("references are renamed when a restored blob comes back under a different id", async () => {
|
||||
const memory = memoryDriver()
|
||||
// A store without WebCrypto assigns a fresh id to every upload.
|
||||
let counter = 0
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
const id = `random-${counter++}`
|
||||
memory.blobs.set(id, blob)
|
||||
return id
|
||||
}
|
||||
const store = createDraftStore(memory.driver)
|
||||
const image = await store.putBlob(new Blob([new Uint8Array([7])], { type: "image/png" }))
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
})
|
||||
const original = JSON.parse(memory.documents.get("doc")!)
|
||||
memory.blobs.clear()
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
})
|
||||
const restored = JSON.parse(memory.documents.get("doc")!)
|
||||
expect(restored.prompt[0].content.blob.ids).not.toEqual(original.prompt[0].content.blob.ids)
|
||||
expect(restored.prompt[1].blob.id).not.toBe(original.prompt[1].blob.id)
|
||||
for (const id of [...restored.prompt[0].content.blob.ids, restored.prompt[1].blob.id])
|
||||
expect(memory.blobs.has(id)).toBe(true)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
const read = JSON.parse((await fresh.getItem("doc"))!)
|
||||
expect(read.prompt[0].content).toBe(paste)
|
||||
expect(read.prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
|
||||
// The renamed ids are what later encodes publish, so saves of the still-live references (the
|
||||
// composer keeps the original image id) upload nothing and keep one stable image id.
|
||||
const puts = counter
|
||||
for (const cursor of [1, 2, 3]) {
|
||||
await store.setDocument("doc", {
|
||||
prompt: [
|
||||
{ type: "text", content: paste },
|
||||
{ type: "image", blob: image },
|
||||
],
|
||||
cursor,
|
||||
})
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[1].blob.id).toBe(restored.prompt[1].blob.id)
|
||||
}
|
||||
expect(counter).toBe(puts)
|
||||
})
|
||||
|
||||
test("chunk boundaries never split a surrogate pair", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
const text = "x".repeat(draftTextChunk - 1) + "😀tail"
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: text }] })
|
||||
const stored = JSON.parse(memory.documents.get("doc")!)
|
||||
const bytes = await Promise.all(stored.prompt[0].content.blob.ids.map((id: string) => memory.blobs.get(id)!.text()))
|
||||
expect(bytes.join("")).toBe(text)
|
||||
expect(bytes[0]!.length).toBe(draftTextChunk + 1)
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(text)
|
||||
})
|
||||
|
||||
test("a failed chunk upload is retried on the next save instead of being reused", async () => {
|
||||
const memory = memoryDriver()
|
||||
let failNext = true
|
||||
const putBlob = memory.driver.putBlob
|
||||
memory.driver.putBlob = async (blob) => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error("offline")
|
||||
}
|
||||
return putBlob(blob)
|
||||
}
|
||||
const store = createDraftStore(memory.driver)
|
||||
await expect(store.setDocument("doc", { prompt: [{ type: "text", content: paste }] })).rejects.toThrow("offline")
|
||||
await store.setDocument("doc", { prompt: [{ type: "text", content: `${paste}!` }] })
|
||||
const fresh = createDraftStore(memory.driver)
|
||||
expect(JSON.parse((await fresh.getItem("doc"))!).prompt[0].content).toBe(`${paste}!`)
|
||||
})
|
||||
|
||||
test("setItem still accepts a serialized document", async () => {
|
||||
const memory = memoryDriver()
|
||||
const store = createDraftStore(memory.driver)
|
||||
await store.setItem("doc", JSON.stringify({ prompt: [{ type: "text", content: large }] }))
|
||||
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -5,20 +5,43 @@ export type BlobReference = { id: string; url: string }
|
||||
|
||||
type Driver = {
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string): Promise<void>
|
||||
/**
|
||||
* Store the document and report every blob id it references that the store does not hold. A
|
||||
* strict write is refused (nothing stored) when any are missing, so a document is never visible
|
||||
* with dangling references while its blobs are being restored.
|
||||
*/
|
||||
set(key: string, value: string, strict: boolean): Promise<readonly string[]>
|
||||
remove(key: string): Promise<void>
|
||||
putBlob(blob: Blob): Promise<string>
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
|
||||
export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
|
||||
export type DraftStore = AsyncStorage & {
|
||||
putBlob(blob: Blob): Promise<BlobReference>
|
||||
/** Persist an already-encoded document without re-parsing its serialized form. */
|
||||
setDocument(key: string, document: unknown): Promise<void>
|
||||
}
|
||||
|
||||
// Strings at least this long leave the document as fixed-size content-addressed chunks. Typing
|
||||
// after a large paste changes only the final chunk, so a save uploads one chunk, not the paste.
|
||||
export const draftTextThreshold = 16 * 1024
|
||||
export const draftTextChunk = 64 * 1024
|
||||
const textCacheLimit = 64
|
||||
|
||||
const urls = new Map<string, string>()
|
||||
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
|
||||
// collected image be uploaded again without fetching the URL.
|
||||
const held = new Map<string, Blob>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
function blobUrl(id: string, blob: Blob) {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.set(id, url)
|
||||
held.set(id, blob)
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -56,25 +79,82 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
const encode = async (value: unknown): Promise<unknown> => {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(encode))
|
||||
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
|
||||
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
|
||||
// the store has since collected the blob: the write reports it missing and it is uploaded again.
|
||||
const chunkIds = new Map<string, Promise<string>>()
|
||||
const chunks = new Map<string, string>()
|
||||
const remember = <V>(cache: Map<string, V>, key: string, value: V) => {
|
||||
cache.set(key, value)
|
||||
if (cache.size > textCacheLimit) cache.delete(cache.keys().next().value!)
|
||||
return value
|
||||
}
|
||||
const upload = (chunk: string) => {
|
||||
const id = driver.putBlob(new Blob([chunk])).then(
|
||||
(id) => {
|
||||
remember(chunks, id, chunk)
|
||||
return id
|
||||
},
|
||||
(error: unknown) => {
|
||||
// A failed upload must not be reused as the answer for this content on later saves.
|
||||
if (chunkIds.get(chunk) === id) chunkIds.delete(chunk)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
return remember(chunkIds, chunk, id)
|
||||
}
|
||||
const externalize = (text: string) => Promise.all(split(text).map((chunk) => chunkIds.get(chunk) ?? upload(chunk)))
|
||||
const loadChunk = async (id: string) => {
|
||||
const cached = chunks.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
const blob = await driver.getBlob(id)
|
||||
// A missing chunk loses that text but keeps the rest of the document decodable.
|
||||
return remember(chunks, id, blob ? await blob.text() : "")
|
||||
}
|
||||
// `sources` collects, for every blob id the encoded document references, a way to produce its
|
||||
// bytes again: the chunk text itself, or the image Blob (or object URL) the reference carries.
|
||||
type Sources = Map<string, { blob: () => Promise<Blob>; chunk?: string }>
|
||||
const encode = async (value: unknown, sources: Sources): Promise<unknown> => {
|
||||
if (typeof value === "string" && value.length >= draftTextThreshold) {
|
||||
const pieces = split(value)
|
||||
const ids = await externalize(value)
|
||||
ids.forEach((id, index) =>
|
||||
sources.set(id, { blob: async () => new Blob([pieces[index]!]), chunk: pieces[index] }),
|
||||
)
|
||||
return { blob: { kind: "text", ids } }
|
||||
}
|
||||
if (Array.isArray(value)) return Promise.all(value.map((entry) => encode(entry, sources)))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.type === "image" && typeof item.dataUrl === "string") {
|
||||
const blob = await fetch(item.dataUrl).then((response) => response.blob())
|
||||
const { dataUrl: _, ...rest } = item
|
||||
return { ...rest, blob: { id: await driver.putBlob(blob) } }
|
||||
const id = await driver.putBlob(blob)
|
||||
sources.set(id, { blob: async () => blob })
|
||||
return { ...rest, blob: { id } }
|
||||
}
|
||||
if ("blob" in item && item.blob && typeof item.blob === "object") {
|
||||
const blob = item.blob as Record<string, unknown>
|
||||
if (blob.kind === "text") return item
|
||||
if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
|
||||
const data = await fetch(blob.id).then((response) => response.blob())
|
||||
return { ...item, blob: { id: await driver.putBlob(data) } }
|
||||
const id = await driver.putBlob(data)
|
||||
sources.set(id, { blob: async () => data })
|
||||
return { ...item, blob: { id } }
|
||||
}
|
||||
if (typeof blob.id === "string") {
|
||||
// A live reference keeps the id it was created with; publish the id its bytes now live under.
|
||||
const id = aliases.get(blob.id) ?? blob.id
|
||||
const kept = held.get(id)
|
||||
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
|
||||
if (kept) sources.set(id, { blob: async () => kept })
|
||||
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
|
||||
return { ...item, blob: { id } }
|
||||
}
|
||||
return { ...item, blob: { id: blob.id } }
|
||||
}
|
||||
return Object.fromEntries(
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry, sources)])),
|
||||
)
|
||||
}
|
||||
const decode = async (value: unknown): Promise<unknown> => {
|
||||
@@ -83,6 +163,9 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.blob && typeof item.blob === "object") {
|
||||
const ref = item.blob as Record<string, unknown>
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
@@ -92,6 +175,65 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
|
||||
)
|
||||
}
|
||||
// Upload the bytes behind `ids` again and return the ids they were stored under. Ids are
|
||||
// usually content hashes and come back unchanged, but a store without WebCrypto assigns fresh
|
||||
// ones, so callers must rename references rather than assume.
|
||||
const restore = async (ids: readonly string[], sources: Sources) => {
|
||||
const renamed = new Map<string, string>()
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const source = sources.get(id)
|
||||
if (!source) return
|
||||
const blob = await source.blob()
|
||||
const next = await driver.putBlob(blob)
|
||||
renamed.set(id, next)
|
||||
if (source.chunk !== undefined) {
|
||||
remember(chunkIds, source.chunk, Promise.resolve(next))
|
||||
remember(chunks, next, source.chunk)
|
||||
return
|
||||
}
|
||||
held.set(next, blob)
|
||||
blobUrl(next, blob)
|
||||
if (next === id) return
|
||||
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
|
||||
// earlier alias chain so lookups stay one step.
|
||||
for (const [from, to] of aliases) if (to === id) aliases.set(from, next)
|
||||
aliases.set(id, next)
|
||||
}),
|
||||
)
|
||||
return renamed
|
||||
}
|
||||
const rename = (value: unknown, renamed: Map<string, string>): unknown => {
|
||||
if (Array.isArray(value)) return value.map((entry) => rename(entry, renamed))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.blob && typeof item.blob === "object") {
|
||||
const ref = item.blob as Record<string, unknown>
|
||||
if (Array.isArray(ref.ids))
|
||||
return { ...item, blob: { ...ref, ids: ref.ids.map((id) => renamed.get(String(id)) ?? id) } }
|
||||
if (typeof ref.id === "string") return { ...item, blob: { ...ref, id: renamed.get(ref.id) ?? ref.id } }
|
||||
}
|
||||
return Object.fromEntries(Object.entries(item).map(([key, entry]) => [key, rename(entry, renamed)]))
|
||||
}
|
||||
const setDocument = async (key: string, document: unknown) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, version)
|
||||
const sources: Sources = new Map()
|
||||
const encoded = await encode(document, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
// The store refuses the write while any referenced blob is missing, so the previous document
|
||||
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
|
||||
// or the composer's history still held its id.
|
||||
const missing = await driver.set(key, JSON.stringify(encoded), true)
|
||||
if (missing.length === 0) return
|
||||
const renamed = await restore(missing, sources)
|
||||
if (versions.get(key) !== version) return
|
||||
const unrestored = missing.filter((id) => !renamed.has(id))
|
||||
if (unrestored.length)
|
||||
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
|
||||
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
|
||||
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
|
||||
}
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const value = await driver.get(key)
|
||||
@@ -101,12 +243,8 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
if (Option.isNone(parsed)) return value
|
||||
return JSON.stringify(await decode(parsed.value))
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, version)
|
||||
const encoded = JSON.stringify(await encode(JSON.parse(value)))
|
||||
if (versions.get(key) === version) await driver.set(key, encoded)
|
||||
},
|
||||
setItem: (key, value) => setDocument(key, JSON.parse(value)),
|
||||
setDocument,
|
||||
removeItem: async (key) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
await driver.remove(key)
|
||||
@@ -115,6 +253,20 @@ export function createDraftStore(driver: Driver): DraftStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-size pieces, except that a piece never ends between the two halves of a surrogate pair:
|
||||
// each piece becomes its own Blob, and an unpaired surrogate would be encoded as U+FFFD.
|
||||
function split(text: string) {
|
||||
const pieces: string[] = []
|
||||
for (let start = 0; start < text.length; ) {
|
||||
const end = Math.min(start + draftTextChunk, text.length)
|
||||
const code = text.charCodeAt(end - 1)
|
||||
const stop = end < text.length && code >= 0xd800 && code <= 0xdbff ? end + 1 : end
|
||||
pieces.push(text.slice(start, stop))
|
||||
start = stop
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
export function createBrowserDraftStore(): DraftStore {
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
@@ -127,11 +279,7 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
const transaction = database.transaction(["documents", "blobs"], "readwrite")
|
||||
const documents = transaction.objectStore("documents").getAll()
|
||||
documents.addEventListener("success", () => {
|
||||
const used = new Set<string>()
|
||||
JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
})
|
||||
const used = referenced(`[${documents.result.join(",")}]`)
|
||||
const store = transaction.objectStore("blobs")
|
||||
const blobs = store.openKeyCursor()
|
||||
blobs.addEventListener("success", () => {
|
||||
@@ -164,7 +312,32 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
}
|
||||
return createDraftStore({
|
||||
get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
|
||||
set: (key, value) => write("documents", key, value),
|
||||
set: async (key, value, strict) => {
|
||||
// One readwrite transaction over both stores: IndexedDB serialises overlapping readwrite
|
||||
// transactions in creation order, so a later save or removal cannot commit between the
|
||||
// reference check and this write. The put is issued from the last lookup's callback so the
|
||||
// transaction is never left without a pending request.
|
||||
const ids = [...referenced(value)]
|
||||
const transaction = (await db).transaction(["blobs", "documents"], "readwrite")
|
||||
const missing: string[] = []
|
||||
const publish = () => {
|
||||
if (!strict || missing.length === 0) transaction.objectStore("documents").put(value, key)
|
||||
}
|
||||
let remaining = ids.length
|
||||
if (remaining === 0) publish()
|
||||
for (const id of ids) {
|
||||
const lookup = transaction.objectStore("blobs").getKey(id)
|
||||
lookup.addEventListener("success", () => {
|
||||
if (lookup.result === undefined) missing.push(id)
|
||||
if (--remaining === 0) publish()
|
||||
})
|
||||
}
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
transaction.addEventListener("complete", () => resolve(missing))
|
||||
transaction.addEventListener("error", () => reject(transaction.error))
|
||||
transaction.addEventListener("abort", () => reject(transaction.error))
|
||||
})
|
||||
},
|
||||
remove: (key) => write("documents", key),
|
||||
putBlob: async (blob) => {
|
||||
const id = await blobID(blob)
|
||||
@@ -175,6 +348,17 @@ export function createBrowserDraftStore(): DraftStore {
|
||||
})
|
||||
}
|
||||
|
||||
// Every blob id a serialized document (or array of documents) references.
|
||||
function referenced(json: string) {
|
||||
const ids = new Set<string>()
|
||||
JSON.parse(json, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") ids.add(item.blob.id)
|
||||
if (item?.blob && Array.isArray(item.blob.ids)) item.blob.ids.forEach((id: unknown) => ids.add(String(id)))
|
||||
return item
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const data = await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
|
||||
|
||||
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
|
||||
// that the test delivers to other windows whenever it chooses, like the real event stream.
|
||||
function host(initial: Record<string, Record<string, string>> = {}) {
|
||||
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
|
||||
const calls: Call[] = []
|
||||
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
|
||||
let revision = 0
|
||||
let fail: (insert: Record<string, string>) => boolean = () => false
|
||||
let gate: Promise<void> | undefined
|
||||
const driver: NamespaceDriver = {
|
||||
items: async (name) => {
|
||||
calls.push({ kind: "items", name })
|
||||
return { items: Object.fromEntries(data.get(name) ?? []), revision }
|
||||
},
|
||||
update: async (name, insert, remove) => {
|
||||
calls.push({ kind: "update", name, insert, remove })
|
||||
await gate
|
||||
if (fail(insert)) throw new Error("disk full")
|
||||
const items = data.get(name) ?? new Map()
|
||||
for (const [key, value] of Object.entries(insert)) items.set(key, value)
|
||||
for (const key of remove) items.delete(key)
|
||||
data.set(name, items)
|
||||
events.push({ name, insert, remove, revision: ++revision })
|
||||
return revision
|
||||
},
|
||||
clear: async (name) => {
|
||||
calls.push({ kind: "clear", name })
|
||||
data.delete(name)
|
||||
},
|
||||
}
|
||||
return {
|
||||
driver,
|
||||
data,
|
||||
calls,
|
||||
events,
|
||||
updates: () => calls.filter((call) => call.kind === "update"),
|
||||
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
|
||||
(fail = typeof value === "boolean" ? () => value : value),
|
||||
setGate: (value: Promise<void> | undefined) => (gate = value),
|
||||
deliver: (target: NamespaceStorage, index: number) => {
|
||||
const event = events[index]!
|
||||
target.accept(event.insert, event.remove, event.revision)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("namespace storage", () => {
|
||||
test("loads the namespace once and serves reads from memory", async () => {
|
||||
const h = host({ w: { tabs: "[]", recent: "{}" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
expect(await storage.getItem("tabs")).toBe("[]")
|
||||
expect(await storage.getItem("recent")).toBe("{}")
|
||||
expect(await storage.getItem("missing")).toBeNull()
|
||||
expect(await storage.getLength()).toBe(2)
|
||||
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reads its own writes immediately and coalesces them into one update", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
void storage.setItem("tabs", "[1,2]")
|
||||
void storage.removeItem("recent")
|
||||
expect(await storage.getItem("tabs")).toBe("[1,2]")
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.updates()).toHaveLength(0)
|
||||
await wait(30)
|
||||
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
|
||||
})
|
||||
|
||||
test("writes made while loading win over the loaded snapshot", async () => {
|
||||
const h = host({ w: { tabs: "old" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
const read = storage.getItem("tabs")
|
||||
void storage.setItem("tabs", "new")
|
||||
expect(await read).toBe("new")
|
||||
})
|
||||
|
||||
test("flush writes now and resolves after the driver accepted the batch", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
|
||||
await storage.flush()
|
||||
expect(h.updates()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
h.setFail(true)
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")).toBeUndefined()
|
||||
h.setFail(false)
|
||||
void storage.setItem("tabs", "[2]")
|
||||
await storage.flush()
|
||||
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
|
||||
})
|
||||
|
||||
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.flush()
|
||||
void storage.setItem("tabs", "[2]")
|
||||
void storage.flush()
|
||||
// Both batches reached the driver while the first reply is still outstanding.
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
})
|
||||
|
||||
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const accepted = Promise.withResolvers<number>()
|
||||
const driver: NamespaceDriver = {
|
||||
items: () => loaded.promise,
|
||||
update: () => accepted.promise,
|
||||
clear: async () => undefined,
|
||||
}
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
void storage.setItem("model", "local")
|
||||
void storage.flush()
|
||||
storage.accept({ model: "other-window-older" }, [], 1)
|
||||
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
|
||||
expect(await read).toBe("local")
|
||||
accepted.resolve(2)
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("local")
|
||||
storage.accept({ model: "other-window-newer" }, [], 3)
|
||||
expect(await storage.getItem("model")).toBe("other-window-newer")
|
||||
})
|
||||
|
||||
test("a failed batch does not requeue a value a later batch already replaced", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
|
||||
h.setFail((insert) => insert.tabs === "old")
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "old")
|
||||
void storage.flush()
|
||||
h.setGate(undefined)
|
||||
void storage.setItem("tabs", "new")
|
||||
void storage.flush()
|
||||
await wait(0)
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
await storage.flush()
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
expect(await storage.getItem("tabs")).toBe("new")
|
||||
})
|
||||
|
||||
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
|
||||
const h = host({ g: { model: "start" } })
|
||||
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await one.getItem("model")
|
||||
await two.getItem("model")
|
||||
void one.setItem("model", "A")
|
||||
await one.flush()
|
||||
void two.setItem("model", "B")
|
||||
await two.flush()
|
||||
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
|
||||
h.deliver(two, 0)
|
||||
expect(await two.getItem("model")).toBe("B")
|
||||
expect(h.data.get("g")?.get("model")).toBe("B")
|
||||
// One still receives B, which is newer than its own ack.
|
||||
h.deliver(one, 1)
|
||||
expect(await one.getItem("model")).toBe("B")
|
||||
})
|
||||
|
||||
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
|
||||
const h = host()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
h.setGate(gate.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
void storage.setItem("model", "mine")
|
||||
void storage.flush()
|
||||
// Another window's write for the same key landed at the host after ours will.
|
||||
storage.accept({ model: "theirs" }, [], 2)
|
||||
expect(await storage.getItem("model")).toBe("mine")
|
||||
gate.resolve()
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("theirs")
|
||||
})
|
||||
|
||||
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
|
||||
storage.accept({ model: "inserted" }, [], 41)
|
||||
loaded.resolve({ items: {}, revision: 42 })
|
||||
expect(await read).toBeNull()
|
||||
// The delete event is older than the floor and must stay a no-op either way.
|
||||
storage.accept({}, ["model"], 42)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
// A key inserted by an event newer than the snapshot survives the load.
|
||||
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
|
||||
const pending = other.getItem("model")
|
||||
other.accept({ model: "after-snapshot" }, [], 43)
|
||||
second.resolve({ items: {}, revision: 42 })
|
||||
expect(await pending).toBe("after-snapshot")
|
||||
})
|
||||
|
||||
test("an event older than the initial load is ignored", async () => {
|
||||
const h = host({ g: { model: "loaded" } })
|
||||
void h.driver.update("g", { model: "loaded" }, [])
|
||||
await wait(0)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await storage.getItem("model")
|
||||
storage.accept({ model: "before-load" }, [], 1)
|
||||
expect(await storage.getItem("model")).toBe("loaded")
|
||||
storage.accept({}, ["model"], 2)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
})
|
||||
|
||||
test("clear drops the cache and queued changes and clears the driver", async () => {
|
||||
const h = host({ w: { tabs: "[]" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
await storage.getItem("tabs")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.clear()
|
||||
expect(await storage.getItem("tabs")).toBeNull()
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
|
||||
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
|
||||
// update with a monotonic revision and reports it with reads, acks, and change events.
|
||||
export type NamespaceDriver = {
|
||||
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
|
||||
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
|
||||
clear(name: string): Promise<void>
|
||||
}
|
||||
|
||||
export type NamespaceStorage = AsyncStorage & {
|
||||
/** Write every queued change now. Resolves when the driver has accepted it. */
|
||||
flush(): Promise<void>
|
||||
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
|
||||
accept(insert: Record<string, string>, remove: string[], revision: number): void
|
||||
}
|
||||
|
||||
export const namespaceFlushDelay = 100
|
||||
|
||||
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
|
||||
// on; writes update the cache immediately and are batched into one driver call per flush window,
|
||||
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
|
||||
//
|
||||
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
|
||||
// recorded until the host acks that exact write; while recorded, nothing external may replace
|
||||
// the key. Every value the cache holds also carries the host revision it came from, so an event
|
||||
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
|
||||
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
|
||||
// on the wire before the page goes away.
|
||||
export function createNamespaceStorage(
|
||||
driver: NamespaceDriver,
|
||||
name: string,
|
||||
options: { delay?: number } = {},
|
||||
): NamespaceStorage {
|
||||
const delay = options.delay ?? namespaceFlushDelay
|
||||
const cache = new Map<string, string>()
|
||||
const local = new Map<string, { seq: number; value: string | null }>()
|
||||
const dirty = new Set<string>()
|
||||
const inflight = new Set<Promise<void>>()
|
||||
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
|
||||
const applied = new Map<string, number>()
|
||||
let floor = -1
|
||||
// The newest external change for a key that arrived while a local write was still in flight.
|
||||
const deferred = new Map<string, { revision: number; value: string | null }>()
|
||||
let seq = 0
|
||||
let loading: Promise<void> | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const place = (key: string, value: string | null, revision: number) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
|
||||
// an older event inserted it into the cache while the load was in flight.
|
||||
const load = () =>
|
||||
(loading ??= driver.items(name).then((loaded) => {
|
||||
floor = loaded.revision
|
||||
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
|
||||
}
|
||||
for (const [key, value] of Object.entries(loaded.items)) {
|
||||
if (stale(key)) place(key, value, loaded.revision)
|
||||
}
|
||||
}))
|
||||
|
||||
const write = (key: string, value: string | null) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
local.set(key, { seq: ++seq, value })
|
||||
dirty.add(key)
|
||||
timer ??= setTimeout(() => void flush(), delay)
|
||||
}
|
||||
|
||||
// The host accepted this window's value for `key` at `revision`. A change from another window
|
||||
// that was held back meanwhile wins if the host applied it later than ours.
|
||||
const acknowledge = (key: string, revision: number) => {
|
||||
local.delete(key)
|
||||
const later = deferred.get(key)
|
||||
deferred.delete(key)
|
||||
if (later && later.revision > revision) return place(key, later.value, later.revision)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (dirty.size > 0) {
|
||||
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
|
||||
dirty.clear()
|
||||
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
|
||||
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
|
||||
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
|
||||
const request = driver
|
||||
.update(name, insert, remove)
|
||||
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
|
||||
.catch((error: unknown) => {
|
||||
// Only a value nothing newer has replaced is worth retrying.
|
||||
batch.filter(current).forEach((entry) => dirty.add(entry.key))
|
||||
console.error(`[persistence] flush failed for ${name}`, error)
|
||||
})
|
||||
.finally(() => inflight.delete(request))
|
||||
inflight.add(request)
|
||||
}
|
||||
return Promise.all(inflight).then(() => undefined)
|
||||
}
|
||||
|
||||
const storage: NamespaceStorage = {
|
||||
getItem: async (key) => {
|
||||
await load()
|
||||
return cache.get(key) ?? null
|
||||
},
|
||||
setItem: async (key, value) => write(key, value),
|
||||
removeItem: async (key) => write(key, null),
|
||||
clear: async () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
cache.clear()
|
||||
local.clear()
|
||||
dirty.clear()
|
||||
applied.clear()
|
||||
deferred.clear()
|
||||
loading = Promise.resolve()
|
||||
await driver.clear(name)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
await load()
|
||||
return [...cache.keys()][index]
|
||||
},
|
||||
getLength: async () => {
|
||||
await load()
|
||||
return cache.size
|
||||
},
|
||||
get length() {
|
||||
return storage.getLength()
|
||||
},
|
||||
flush,
|
||||
accept(insert, remove, revision) {
|
||||
// The initial load already reflects everything up to `floor`.
|
||||
if (revision <= floor) return
|
||||
const changes = [
|
||||
...Object.entries(insert).map(([key, value]) => [key, value] as const),
|
||||
...remove.map((key) => [key, null] as const),
|
||||
]
|
||||
for (const [key, value] of changes) {
|
||||
if (local.has(key)) {
|
||||
const held = deferred.get(key)
|
||||
if (!held || revision > held.revision) deferred.set(key, { revision, value })
|
||||
continue
|
||||
}
|
||||
if (revision <= (applied.get(key) ?? floor)) continue
|
||||
place(key, value, revision)
|
||||
}
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { flushPersisted, persistStore } from "./persist"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type State = { count: number; label: string }
|
||||
|
||||
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
|
||||
const writes: string[] = []
|
||||
return createRoot((dispose) => {
|
||||
const [store, setStore] = createStore<State>({ count: 0, label: "" })
|
||||
const persist = persistStore({
|
||||
store,
|
||||
setStore,
|
||||
name: "state",
|
||||
// One fixture serves both the sync and the async storage shape.
|
||||
storage: {
|
||||
getItem: () => input.initial ?? null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value)
|
||||
},
|
||||
removeItem: () => {},
|
||||
} as SyncStorage | AsyncStorage,
|
||||
serialize: JSON.stringify,
|
||||
deserialize: JSON.parse,
|
||||
sync: input.sync,
|
||||
delay: input.delay ?? 10,
|
||||
})
|
||||
return { store, set: persist.setStore, persist, writes, dispose }
|
||||
})
|
||||
}
|
||||
|
||||
describe("persistStore", () => {
|
||||
test("marks the store dirty on set and writes once after the delay", async () => {
|
||||
const value = setup({})
|
||||
value.set("count", 1)
|
||||
value.set("count", 2)
|
||||
value.set("label", "a")
|
||||
expect(value.store.count).toBe(2)
|
||||
expect(value.writes).toEqual([])
|
||||
await wait(30)
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("skips the write when the serialized value did not change", () => {
|
||||
const value = setup({ delay: 10_000 })
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("hydrates synchronously from sync storage without writing back", () => {
|
||||
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
|
||||
expect(value.store).toEqual({ count: 5, label: "saved" })
|
||||
value.persist.flush()
|
||||
expect(value.writes).toEqual([])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a set made while async storage loads wins over the loaded value", async () => {
|
||||
const loading = Promise.withResolvers<string | null>()
|
||||
const value = setup({ initial: loading.promise, delay: 10_000 })
|
||||
value.set("count", 9)
|
||||
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
|
||||
await loading.promise
|
||||
expect(value.store.count).toBe(9)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("applies another window's value when clean and ignores it while dirty", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "remote" })
|
||||
value.set("label", "local")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "local" })
|
||||
value.persist.flush()
|
||||
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
// Setting the same value again marks the store dirty without changing it.
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store.label).toBe("")
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "remote" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(sent).toHaveLength(1)
|
||||
// A later save must not consider the adopted value a local change.
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("label", "saved")
|
||||
value.persist.flush()
|
||||
value.set("label", "saved")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 0, label: "saved" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "" })
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("disposing the owner and flushPersisted both save pending changes", () => {
|
||||
const first = setup({ delay: 10_000 })
|
||||
first.set("count", 1)
|
||||
first.dispose()
|
||||
expect(first.writes).toHaveLength(1)
|
||||
|
||||
const second = setup({ delay: 10_000 })
|
||||
second.set("count", 2)
|
||||
flushPersisted()
|
||||
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
|
||||
second.dispose()
|
||||
expect(second.writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
|
||||
export const persistSaveDelay = 100
|
||||
|
||||
const pending = new Set<() => void>()
|
||||
|
||||
/** Serialize and write every store with unsaved changes now. */
|
||||
export function flushPersisted() {
|
||||
for (const save of [...pending]) save()
|
||||
}
|
||||
|
||||
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
|
||||
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushPersisted()
|
||||
})
|
||||
window.addEventListener("pagehide", flushPersisted)
|
||||
}
|
||||
|
||||
// A store whose serialized form is written to storage on a schedule instead of on every setter
|
||||
// call. The setter only marks the store dirty; serialization happens once per save window, once
|
||||
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
|
||||
export function persistStore<T extends object>(input: {
|
||||
store: Store<T>
|
||||
setStore: SetStoreFunction<T>
|
||||
name: string
|
||||
storage: SyncStorage | AsyncStorage
|
||||
serialize: (value: T) => string
|
||||
deserialize: (raw: string) => T
|
||||
sync?: PersistenceSyncAPI
|
||||
delay?: number
|
||||
/** Replaces `storage.setItem` for stores whose storage can take the value itself. */
|
||||
write?: (value: T, serialized: string) => void
|
||||
}) {
|
||||
const delay = input.delay ?? persistSaveDelay
|
||||
let dirty = false
|
||||
let touched = false
|
||||
let last: string | undefined
|
||||
// The newest value another window wrote while this store was dirty; applied at save time if the
|
||||
// local setter calls turned out not to change anything.
|
||||
let remote: string | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const save = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
pending.delete(save)
|
||||
if (!dirty) return
|
||||
dirty = false
|
||||
const held = remote
|
||||
remote = undefined
|
||||
const next = untrack(() => input.serialize(input.store))
|
||||
if (next === last) {
|
||||
if (held !== undefined && held !== last) hydrate(held)
|
||||
return
|
||||
}
|
||||
last = next
|
||||
input.sync?.[1](input.name, next)
|
||||
if (input.write) return input.write(input.store, next)
|
||||
void input.storage.setItem(input.name, next)
|
||||
}
|
||||
|
||||
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
|
||||
const apply = input.setStore as unknown as (...values: unknown[]) => void
|
||||
const setStore = ((...values: unknown[]) => {
|
||||
apply(...values)
|
||||
dirty = true
|
||||
touched = true
|
||||
pending.add(save)
|
||||
timer ??= setTimeout(save, delay)
|
||||
}) as unknown as SetStoreFunction<T>
|
||||
|
||||
const hydrate = (raw: string) => {
|
||||
last = raw
|
||||
input.setStore(reconcile(input.deserialize(raw)))
|
||||
}
|
||||
const init = input.storage.getItem(input.name)
|
||||
// A value the user already changed is newer than whatever storage held.
|
||||
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
|
||||
else if (init) hydrate(init)
|
||||
|
||||
input.sync?.[0]((data) => {
|
||||
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
|
||||
if (!data.newValue) return
|
||||
// A real unsaved local change wins over another window's write, as in VS Code's storage
|
||||
// service; whether the change is real is only known when the store is serialized. Every
|
||||
// remote value replaces the held one, including a revert to `last`, so the save sees the
|
||||
// other window's final state rather than an intermediate one.
|
||||
if (dirty) {
|
||||
remote = data.newValue
|
||||
return
|
||||
}
|
||||
if (data.newValue === last) return
|
||||
hydrate(data.newValue)
|
||||
})
|
||||
|
||||
if (getOwner()) onCleanup(save)
|
||||
|
||||
return { setStore, init, flush: save }
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Platform, usePlatform } from "@/runtime/platform/platform"
|
||||
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type InitType = Promise<string | null> | string | null
|
||||
type PersistedWithReady<T> = [
|
||||
Store<T>,
|
||||
SetStoreFunction<T>,
|
||||
@@ -483,6 +484,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
const initialized = Persistence.withInitial(schema, initial)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
const encode = Schema.encodeSync(initialized)
|
||||
const serialize = Schema.encodeSync(json)
|
||||
const normalize = (raw: string) => {
|
||||
const value = decode(raw)
|
||||
@@ -491,10 +493,12 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
// The newest serialized draft, replayed into storage if a slow load finishes after an edit.
|
||||
let draftLatest: string | undefined
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (draft) {
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
return {
|
||||
getItem: (key: string) => draft.getItem(prefix + key),
|
||||
setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
|
||||
@@ -556,7 +560,6 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
]
|
||||
.filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage)
|
||||
.map((source) => ({ ...source, storage: toAsyncStorage(source.storage) }))
|
||||
let draftLatest: string | undefined
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
@@ -593,13 +596,29 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
|
||||
const persist = persistStore({
|
||||
store: store[0],
|
||||
setStore: store[1],
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
// Drafts take the encoded document itself so large text is externalized without the store
|
||||
// re-parsing the serialized form on every save.
|
||||
write: draft
|
||||
? (value, serialized) => {
|
||||
draftLatest = serialized
|
||||
// A failed chunk upload is retried by the next save; see drafts.ts.
|
||||
void draft
|
||||
.setDocument(prefix + config.key, encode(value))
|
||||
.catch((error: unknown) => console.error(`[persistence] draft write failed for ${config.key}`, error))
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
const state = store[0]
|
||||
const setState = persist.setStore
|
||||
const init = persist.init
|
||||
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = {
|
||||
tabID: Browser.TabID
|
||||
visible: boolean
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
background?: readonly [number, number, number, number]
|
||||
radius?: number
|
||||
}
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
export function KeyboardInsets() {
|
||||
onMount(() => {
|
||||
const viewport = window.visualViewport
|
||||
if (!viewport) return
|
||||
const root = document.documentElement
|
||||
const sync = () => {
|
||||
const active = document.activeElement
|
||||
const editing =
|
||||
active instanceof HTMLElement &&
|
||||
(active.isContentEditable || active.matches("input:not([readonly]), textarea:not([readonly])"))
|
||||
// iOS retains the home-indicator inset above its keyboard. Ignore pinch zoom
|
||||
// and small viewport changes from browser chrome, not just editor focus.
|
||||
const keyboard = editing && root.clientHeight - viewport.height * viewport.scale > 100
|
||||
if (keyboard) root.style.setProperty("--safe-area-inset-bottom", "0px")
|
||||
if (!keyboard) root.style.removeProperty("--safe-area-inset-bottom")
|
||||
}
|
||||
sync()
|
||||
viewport.addEventListener("resize", sync)
|
||||
window.addEventListener("resize", sync)
|
||||
document.addEventListener("focusin", sync)
|
||||
document.addEventListener("focusout", sync)
|
||||
onCleanup(() => {
|
||||
viewport.removeEventListener("resize", sync)
|
||||
window.removeEventListener("resize", sync)
|
||||
document.removeEventListener("focusin", sync)
|
||||
document.removeEventListener("focusout", sync)
|
||||
root.style.removeProperty("--safe-area-inset-bottom")
|
||||
})
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -118,6 +119,9 @@ type PlatformBase = {
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[key] !== child) return
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
|
||||
export type BrowserAttachment = {
|
||||
registration?: BrowserPaneRegistration
|
||||
browser: BrowserPaneState
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Live = {
|
||||
server: Server
|
||||
sessionID: string
|
||||
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
|
||||
tab?: string
|
||||
registration?: BrowserPaneRegistration
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
attempts: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
|
||||
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
|
||||
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
|
||||
name: "BrowserAttachments",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
)
|
||||
const close = (id: string) => {
|
||||
live.get(id)?.dispose()
|
||||
live.delete(id)
|
||||
setStore(id, undefined)
|
||||
}
|
||||
createEffect(() => {
|
||||
const on = enabled()
|
||||
const tabs = shellTabs.store
|
||||
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
|
||||
Object.keys(store).forEach((id) => {
|
||||
const entry = live.get(id)
|
||||
if (!entry) return
|
||||
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
|
||||
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
|
||||
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
|
||||
if (current) entry.tab = tabKey(current)
|
||||
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
|
||||
if (on && owned && !entry.server.health?.incompatible) return
|
||||
close(id)
|
||||
})
|
||||
})
|
||||
onCleanup(() => Array.from(live.keys()).forEach(close))
|
||||
|
||||
return {
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
|
||||
live.set(id, entry)
|
||||
setStore(id, { browser: null })
|
||||
const register = () => {
|
||||
if (entry.registration || live.get(id) !== entry) return
|
||||
// The server's shared transport follows a restarted sidecar's port whether or not any route
|
||||
// for this session is mounted; the connection captured at attach time may predate it.
|
||||
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
|
||||
const registration = pane.register({ sessionID, endpoint }, (event) => {
|
||||
if (live.get(id) !== entry) return
|
||||
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
return close(id)
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
return setStore(id, {
|
||||
registration: undefined,
|
||||
browser: null,
|
||||
error: language.t("session.browser.replaced"),
|
||||
})
|
||||
}
|
||||
// The desktop dropped the attachment (server restart, attach race). Re-register so the
|
||||
// agent's browser tool comes back without a reload.
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
setStore(id, { registration: undefined, browser: null, error: undefined })
|
||||
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
return
|
||||
}
|
||||
if (event.state) entry.attempts = 0
|
||||
batch(() => {
|
||||
setStore(id, "browser", reconcile(event.state))
|
||||
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
|
||||
})
|
||||
})
|
||||
entry.registration = registration
|
||||
setStore(id, { registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes. The listener
|
||||
// belongs to this provider, not to the route effect that happened to call attach().
|
||||
const data = server.ctx.data
|
||||
const unsubscribe = runWithOwner(owner, () =>
|
||||
data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
}),
|
||||
)
|
||||
if (!data.session.creating(sessionID)) register()
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.()
|
||||
clearTimeout(entry.retry)
|
||||
entry.registration?.close()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const registration = live.get(key(server, sessionID))?.registration
|
||||
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
return registration.command(command)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
import { useBrowserAttachments } from "./attachments"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const attachments = useBrowserAttachments()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [local, setLocal] = createStore({ error: undefined as string | undefined })
|
||||
const attachment = () => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
return sessionID ? attachments.state(server, sessionID) : undefined
|
||||
}
|
||||
const available = createMemo(
|
||||
() =>
|
||||
attachments.enabled() &&
|
||||
attachments.supported(server) &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible &&
|
||||
session.isDesktop(),
|
||||
)
|
||||
const attached = () => attachment()?.registration !== undefined
|
||||
const browserTabs = createMemo(
|
||||
() =>
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
setLocal("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void attachments.command(server, sessionID, command).catch(() => {
|
||||
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().active(),
|
||||
(active) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
|
||||
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
|
||||
.forEach((key) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
|
||||
if (tab) command({ type: "tabs.close", tabID: tab.id })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
|
||||
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
|
||||
createEffect(
|
||||
on(
|
||||
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
|
||||
(ids, previous) => {
|
||||
if (!ids) return
|
||||
const known = new Set(previous ?? [])
|
||||
batch(() => {
|
||||
const tabs = session.layout.tabs()
|
||||
tabs
|
||||
.all()
|
||||
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
|
||||
.forEach(tabs.close)
|
||||
const current = tabs.all()
|
||||
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
|
||||
if (added.length) tabs.setAll([...current, ...added])
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
available,
|
||||
attached,
|
||||
opened: () => attached() && browserTabs().length > 0,
|
||||
state: () => attachment()?.browser ?? null,
|
||||
tabs: browserTabs,
|
||||
active: () =>
|
||||
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
|
||||
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
|
||||
browserTabs()[0],
|
||||
error: () => local.error ?? attachment()?.error,
|
||||
registration: () => attachment()?.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Loader } from "@opencode-ai/ui/loader"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.active
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
if (!tab) {
|
||||
props.registration.setLayout()
|
||||
return
|
||||
}
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
|
||||
).backgroundColor
|
||||
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
// Let the browser resolve the semantic backdrop color, including custom
|
||||
// themes using color formats that Electron's color parser cannot read.
|
||||
if (paint) {
|
||||
paint.clearRect(0, 0, 1, 1)
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
props.registration.setLayout({
|
||||
tabID: tab.id,
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
|
||||
radius: Math.round(10 * zoom),
|
||||
})
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => platform.webviewZoom?.(),
|
||||
() => dialog.active,
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
],
|
||||
() => schedule(300),
|
||||
),
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => surface, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
portals.observe(document.body, { childList: true })
|
||||
onCleanup(() => portals.disconnect())
|
||||
const appearance = new MutationObserver(() => schedule(300))
|
||||
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
|
||||
onCleanup(() => appearance.disconnect())
|
||||
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
import FileTree from "@/session/files/file-tree"
|
||||
@@ -20,6 +21,8 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
const browserTabID = "session-side-panel-browser-tab"
|
||||
const browserTabPanelID = "session-side-panel-browser-tabpanel"
|
||||
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
@@ -33,6 +36,8 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
@@ -41,6 +46,8 @@ import {
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -65,6 +72,7 @@ export function SessionSidePanel(props: {
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -165,6 +173,7 @@ export function SessionSidePanel(props: {
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -202,7 +211,7 @@ export function SessionSidePanel(props: {
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
const browserTab = createMemo(() => {
|
||||
const fileTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && file.pathFromTab(active)) return active
|
||||
@@ -212,11 +221,11 @@ export function SessionSidePanel(props: {
|
||||
// selects Review while the tab For replaces a preview trigger, which would
|
||||
// otherwise dispose the sidebar and reset scroll.
|
||||
const fileBrowserMounted = createMemo(() => {
|
||||
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
|
||||
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -298,7 +307,7 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
@@ -345,8 +354,7 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
<For each={panelTabs()}>
|
||||
{(tab) => (
|
||||
<Show
|
||||
when={tab === SESSION_OPEN_FILE_TAB}
|
||||
<Switch
|
||||
fallback={
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
@@ -357,73 +365,143 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
onTabClose={() => props.browser.close(item().id)}
|
||||
id={`${browserTabID}-${item().id}`}
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={tab === SESSION_OPEN_FILE_TAB}>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{/* With only files to add, the plus stays a one-click "Open file" button. */}
|
||||
<Show
|
||||
when={props.browser.available()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
value={language.t("session.tab.add")}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("session.tab.add")}
|
||||
// The tablist redirects focus entering it to the selected
|
||||
// tab, which counts as focus-outside and closes the menu.
|
||||
onPointerDown={(event: PointerEvent) => event.preventDefault()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
onSelect={openFileBrowser}
|
||||
shortcut={
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
@@ -474,6 +552,30 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
|
||||
}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
|
||||
inert={!isSessionBrowserTab(activeTab()) || undefined}
|
||||
>
|
||||
<Show when={props.browser.registration()} keyed>
|
||||
{(registration) => (
|
||||
<SessionBrowserPane
|
||||
registration={registration}
|
||||
browser={props.browser}
|
||||
visible={isSessionBrowserTab(activeTab())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
<div
|
||||
id={fileBrowserTabPanelID}
|
||||
@@ -484,11 +586,11 @@ export function SessionSidePanel(props: {
|
||||
inert={!fileBrowserVisible() || undefined}
|
||||
>
|
||||
<SessionFileBrowserTab
|
||||
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={
|
||||
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
}
|
||||
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
@@ -15,6 +15,10 @@ export function SortableTab(props: {
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
/** Replaces the file visual for non-file tabs such as the browser. */
|
||||
children?: JSX.Element
|
||||
id?: string
|
||||
ariaControls?: string
|
||||
}): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
@@ -29,6 +33,7 @@ export function SortableTab(props: {
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const custom = children(() => props.children)
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
@@ -39,6 +44,8 @@ export function SortableTab(props: {
|
||||
<div class="relative">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
id={props.id}
|
||||
aria-controls={props.ariaControls}
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
closeButton={
|
||||
@@ -63,7 +70,9 @@ export function SortableTab(props: {
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
<Show when={custom()} fallback={<Show when={content()}>{(value) => value()}</Show>}>
|
||||
{custom()}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
@@ -233,4 +235,49 @@ describe("createSessionTabs", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
|
||||
const first = sessionBrowserTab("first")
|
||||
const second = sessionBrowserTab("second")
|
||||
const input = {
|
||||
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
|
||||
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
|
||||
normalizeTab: (tab: string) => tab,
|
||||
}
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => true })
|
||||
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
|
||||
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe(second)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(second)
|
||||
dispose()
|
||||
})
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => false })
|
||||
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe("file://src/a.ts")
|
||||
expect(result.closableTab()).toBe("file://src/a.ts")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
} from "@/shell/state/session-tabs"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
|
||||
@@ -20,6 +25,7 @@ type TabsInput = {
|
||||
review?: Accessor<boolean>
|
||||
hasReview?: Accessor<boolean>
|
||||
fileBrowser?: Accessor<boolean>
|
||||
browser?: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
@@ -30,6 +36,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||
const browser = input.browser ?? (() => false)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openFileOpen = createMemo(
|
||||
() =>
|
||||
@@ -44,6 +51,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
@@ -54,13 +62,16 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
|
||||
equals: same,
|
||||
})
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
@@ -70,15 +81,16 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
if (review() && hasReview()) return "review"
|
||||
return "empty"
|
||||
})
|
||||
const activeFileTab = createMemo(() => {
|
||||
const activeFileTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
const closableTab = createMemo(() => {
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useData } from "@/runtime/server/current"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useBrowserAttachments } from "./browser/attachments"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -28,6 +29,7 @@ export function useSessionModel() {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const shellTabs = useTabs()
|
||||
const attachments = useBrowserAttachments()
|
||||
const layout = useSessionLayout()
|
||||
const location = useWorkspaceLocation()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -82,6 +84,11 @@ export function useSessionModel() {
|
||||
review: isDesktop,
|
||||
hasReview: canReview,
|
||||
fileBrowser: () => isDesktop() && !!sessionID(),
|
||||
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
|
||||
browser: () => {
|
||||
const id = sessionID()
|
||||
return !!id && attachments.state(server, id)?.registration !== undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -144,7 +145,11 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -168,6 +173,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -47,6 +48,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
@@ -377,7 +379,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -12,6 +13,7 @@ const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,6 +31,22 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("settings schema", () => {
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -130,6 +131,12 @@ describe("settings schema", () => {
|
||||
expect(decode(encode(settings))).toEqual(settings)
|
||||
})
|
||||
|
||||
test("browser attachment is opt-in and preserves an explicit choice", () => {
|
||||
expect(decode({}).general.experimentalBrowser).toBe(false)
|
||||
expect(decode({ general: { experimentalBrowser: true } }).general.experimentalBrowser).toBe(true)
|
||||
expect(decode({ general: { experimentalBrowser: false } }).general.experimentalBrowser).toBe(false)
|
||||
})
|
||||
|
||||
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
|
||||
"defaults malformed sections without losing other sections: %j",
|
||||
(invalid) => {
|
||||
|
||||
@@ -95,6 +95,7 @@ const generalSchema = Persistence.struct({
|
||||
mobileDiffWrap: Schema.Boolean,
|
||||
terminalPlacement: Schema.Literals(["side", "bottom"]),
|
||||
followUpBehavior: Schema.Literals(["queue", "steer"]),
|
||||
experimentalBrowser: Schema.Boolean,
|
||||
})
|
||||
|
||||
const appearanceSchema = Persistence.struct({
|
||||
@@ -251,6 +252,7 @@ export const defaultSettings: Settings = {
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
|
||||
keybinds: {},
|
||||
@@ -358,6 +360,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowUpBehavior(value: FollowUpBehavior) {
|
||||
setStore("general", "followUpBehavior", value)
|
||||
},
|
||||
experimentalBrowser: withFallback(
|
||||
() => store.general?.experimentalBrowser,
|
||||
defaultSettings.general.experimentalBrowser,
|
||||
),
|
||||
setExperimentalBrowser(value: boolean) {
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
[data-component="timeline-detail-control"] {
|
||||
--timeline-detail-track-background: var(--v2-background-bg-layer-04);
|
||||
--timeline-detail-marker-background: var(--v2-grey-500);
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -10,48 +12,55 @@
|
||||
letter-spacing: -0.04px;
|
||||
container: timeline-detail / inline-size;
|
||||
|
||||
[data-slot="timeline-detail-heading"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-current"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-summary"] {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-scale"] {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
margin-top: 24px;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-track"] {
|
||||
position: absolute;
|
||||
inset-inline: 8px;
|
||||
inset-inline: 0;
|
||||
top: 12px;
|
||||
height: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 2px;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
background: var(--timeline-detail-track-background);
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
width: var(--timeline-detail-progress);
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--v2-background-bg-accent);
|
||||
}
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-sizing: content-box;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 2px solid var(--v2-background-bg-layer-01);
|
||||
border-radius: 50%;
|
||||
background: var(--v2-border-border-strong);
|
||||
background: var(--timeline-detail-marker-background);
|
||||
|
||||
&:dir(rtl) {
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +68,11 @@
|
||||
appearance: none;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* Native ranges reserve half the thumb width at each end of their track. */
|
||||
width: calc(100% + 16px);
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
margin-inline: -8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
@@ -81,9 +92,15 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -6px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
|
||||
),
|
||||
var(--v2-grey-300);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
|
||||
@@ -96,28 +113,34 @@
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-background-bg-base);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
|
||||
),
|
||||
var(--v2-grey-300);
|
||||
box-shadow: var(--v2-elevation-button-neutral);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] {
|
||||
margin-top: 4px;
|
||||
margin-top: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
|
||||
width: fit-content;
|
||||
height: 28px;
|
||||
height: 24px;
|
||||
align-self: flex-start;
|
||||
gap: 6px;
|
||||
font-size: inherit;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: inherit;
|
||||
color: var(--v2-text-text-muted);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
width: 16px;
|
||||
@@ -133,20 +156,28 @@
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
|
||||
--timeline-detail-columns: minmax(0, 1fr) 72px 88px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-explainer"] {
|
||||
margin-bottom: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-field-label"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-unavailable"] {
|
||||
width: 24px;
|
||||
border-block-start: 1px solid var(--v2-icon-icon-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-list"] {
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"],
|
||||
[data-slot="timeline-detail-category"] {
|
||||
display: grid;
|
||||
@@ -155,6 +186,10 @@
|
||||
column-gap: 8px;
|
||||
line-height: var(--line-height-compact);
|
||||
|
||||
> :nth-child(3) {
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow-wrap: normal;
|
||||
@@ -162,49 +197,78 @@
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-columns"] {
|
||||
min-height: 28px;
|
||||
min-height: var(--line-height-compact);
|
||||
margin-bottom: 8px;
|
||||
padding-inline: 12.5px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
span:not(:first-child) {
|
||||
padding-inline-start: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-category"] {
|
||||
min-height: 40px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"],
|
||||
[data-slot="timeline-detail-placement"],
|
||||
[data-slot="timeline-detail-expansion"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="select-v2-root"][data-field] {
|
||||
width: 100%;
|
||||
&[data-hidden] [data-slot="timeline-detail-activity"] {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="timeline-detail-activity"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
|
||||
> label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-component="select-v2"][data-appearance="inline"] {
|
||||
width: 100%;
|
||||
[data-component="tooltip-v2-trigger"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-action="timeline-detail-visibility"][aria-pressed="true"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
/* Labels also activate :hover on their associated button. Keep that feedback local to the icon. */
|
||||
&:has(> label:hover) [data-action="timeline-detail-visibility"][data-variant="ghost-muted"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
|
||||
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
|
||||
--timeline-detail-marker-background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
|
||||
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
|
||||
),
|
||||
var(--v2-grey-300);
|
||||
}
|
||||
|
||||
@container timeline-detail (max-width: 250px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -214,20 +278,20 @@
|
||||
row-gap: 8px;
|
||||
padding-block: 12px;
|
||||
|
||||
> span {
|
||||
font-weight: 530;
|
||||
> :nth-child(3) {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
padding-inline-start: 8px;
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-placement"]:empty,
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -235,7 +299,7 @@
|
||||
|
||||
@container timeline-detail (max-width: 310px) {
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
|
||||
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
|
||||
--timeline-detail-columns: minmax(0, 1fr) 64px 80px;
|
||||
}
|
||||
|
||||
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { timelinePresets, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { SettingsList } from "./list"
|
||||
import { TimelineDetailControl } from "./timeline-detail"
|
||||
import "./settings.css"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Settings/Timeline detail",
|
||||
id: "settings-timeline-detail",
|
||||
}
|
||||
|
||||
export const Interactive = {
|
||||
render: () => {
|
||||
const [state, setState] = createStore<{ value: TimelineDetail; visible: boolean }>({
|
||||
value: structuredClone(timelinePresets[2].value),
|
||||
visible: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex w-[560px] max-w-full flex-col gap-4">
|
||||
<Show when={state.visible}>
|
||||
<SettingsList>
|
||||
<div class="py-5">
|
||||
<TimelineDetailControl value={state.value} onChange={(value) => setState("value", value)} />
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
<Button onClick={() => setState("visible", !state.visible)}>
|
||||
{state.visible ? "Leave settings" : "Return to settings"}
|
||||
</Button>
|
||||
<Button onClick={() => setState("value", structuredClone(timelinePresets[2].value))}>Reset</Button>
|
||||
<output data-slot="timeline-detail-fixture-value" hidden>
|
||||
{JSON.stringify(state.value)}
|
||||
</output>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
import { For, Show, createMemo, createUniqueId } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import {
|
||||
timelineCategories,
|
||||
timelinePreset,
|
||||
timelinePresets,
|
||||
type TimelineCategory,
|
||||
type TimelineDetail,
|
||||
type TimelineExpansion,
|
||||
type TimelinePlacement,
|
||||
} from "@opencode-ai/session-ui/timeline/detail"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./timeline-detail.css"
|
||||
|
||||
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
|
||||
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
|
||||
const presets = timelinePresets.toReversed()
|
||||
|
||||
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
|
||||
const language = useLanguage()
|
||||
const id = createUniqueId()
|
||||
const [visiblePlacements, setVisiblePlacements] = createStore<
|
||||
Partial<Record<TimelineCategory, Exclude<TimelinePlacement, "hidden">>>
|
||||
>({})
|
||||
const preset = createMemo(() => timelinePreset(props.value))
|
||||
const position = () => {
|
||||
const current = preset()
|
||||
return current ? timelinePresets.indexOf(current) : 2
|
||||
return current ? presets.indexOf(current) : 2
|
||||
}
|
||||
const label = () => {
|
||||
const current = preset()
|
||||
@@ -30,34 +36,42 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
|
||||
|
||||
return (
|
||||
<div data-component="timeline-detail-control">
|
||||
<div data-slot="timeline-detail-heading">
|
||||
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
|
||||
<span data-slot="timeline-detail-current" aria-live="polite">
|
||||
{label()}
|
||||
</span>
|
||||
<div data-slot="settings-row-copy">
|
||||
<label data-slot="settings-row-title" for={`${id}-slider`}>
|
||||
{language.t("settings.timeline.detail")}
|
||||
</label>
|
||||
<div id={`${id}-description`} data-slot="settings-row-description">
|
||||
{language.t("settings.timeline.description")}
|
||||
</div>
|
||||
</div>
|
||||
<p id={`${id}-description`} class="sr-only">
|
||||
{language.t("settings.timeline.description")}
|
||||
</p>
|
||||
<div data-slot="timeline-detail-scale">
|
||||
<div data-slot="timeline-detail-track" aria-hidden="true">
|
||||
<For each={timelinePresets}>{() => <span />}</For>
|
||||
<div
|
||||
data-slot="timeline-detail-track"
|
||||
aria-hidden="true"
|
||||
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
|
||||
>
|
||||
<For each={presets}>
|
||||
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
|
||||
</For>
|
||||
</div>
|
||||
<input
|
||||
id={`${id}-slider`}
|
||||
data-action="settings-timeline-detail"
|
||||
type="range"
|
||||
min="0"
|
||||
max={timelinePresets.length - 1}
|
||||
max={presets.length - 1}
|
||||
step="1"
|
||||
value={position()}
|
||||
aria-valuetext={label()}
|
||||
aria-describedby={`${id}-description ${id}-preset-description`}
|
||||
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
|
||||
onInput={(event) => props.onChange({ ...presets[event.currentTarget.valueAsNumber].value })}
|
||||
/>
|
||||
</div>
|
||||
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
|
||||
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
|
||||
<p id={`${id}-preset-description`} aria-live="polite">
|
||||
<span data-slot="timeline-detail-summary">{language.t("settings.timeline.summary", { preset: label() })}</span>{" "}
|
||||
{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}
|
||||
</p>
|
||||
<Collapsible variant="ghost" data-slot="timeline-detail-advanced" defaultOpen={!preset()}>
|
||||
<Collapsible.Trigger>
|
||||
<span>{language.t("settings.timeline.advanced")}</span>
|
||||
<Collapsible.Arrow />
|
||||
@@ -68,57 +82,122 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
|
||||
role="group"
|
||||
aria-label={language.t("settings.timeline.advanced.description")}
|
||||
>
|
||||
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
|
||||
<div data-slot="timeline-detail-columns">
|
||||
<span>{language.t("settings.timeline.activity")}</span>
|
||||
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
|
||||
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
|
||||
<span aria-hidden="true" />
|
||||
<span>{language.t("settings.timeline.group")}</span>
|
||||
<span>{language.t("settings.timeline.collapse")}</span>
|
||||
</div>
|
||||
<For each={timelineCategories}>
|
||||
{(category) => (
|
||||
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
|
||||
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
|
||||
<div data-slot="timeline-detail-placement">
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.placement.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="placement"
|
||||
aria-labelledby={`${id}-${category} ${id}-placement`}
|
||||
options={placements}
|
||||
current={props.value[category].placement}
|
||||
label={(value) => language.t(`settings.timeline.placement.${value}`)}
|
||||
onSelect={(placement) =>
|
||||
placement &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="timeline-detail-expansion">
|
||||
{category === "shell" || category === "edit" || category === "thinking" ? (
|
||||
<Show when={props.value[category].placement !== "hidden"}>
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.expansion.title")}
|
||||
</span>
|
||||
<Select
|
||||
data-category={category}
|
||||
data-field="details"
|
||||
aria-labelledby={`${id}-${category} ${id}-expansion`}
|
||||
options={expansions}
|
||||
current={props.value[category].details}
|
||||
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
|
||||
onSelect={(details) =>
|
||||
details &&
|
||||
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
|
||||
<div data-slot="timeline-detail-list">
|
||||
<For each={timelineCategories}>
|
||||
{(category) => (
|
||||
<div
|
||||
data-slot="timeline-detail-category"
|
||||
data-hidden={props.value[category].placement === "hidden" ? "" : undefined}
|
||||
role="group"
|
||||
aria-labelledby={`${id}-${category}`}
|
||||
>
|
||||
<div data-slot="timeline-detail-activity">
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t(
|
||||
props.value[category].placement === "hidden"
|
||||
? "settings.timeline.visibility.show"
|
||||
: "settings.timeline.visibility.hide",
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
id={`${id}-${category}-visibility`}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
data-action="timeline-detail-visibility"
|
||||
aria-label={language.t("settings.timeline.visibility.label", {
|
||||
activity: language.t(`settings.timeline.category.${category}`),
|
||||
})}
|
||||
aria-pressed={props.value[category].placement !== "hidden"}
|
||||
onClick={() => {
|
||||
const placement = props.value[category].placement
|
||||
if (placement !== "hidden") setVisiblePlacements(category, placement)
|
||||
props.onChange({
|
||||
...props.value,
|
||||
[category]: {
|
||||
...props.value[category],
|
||||
placement:
|
||||
placement === "hidden" ? (visiblePlacements[category] ?? "grouped") : "hidden",
|
||||
},
|
||||
})
|
||||
}}
|
||||
icon={
|
||||
<Icon
|
||||
name={props.value[category].placement === "hidden" ? "outline-eye-slash" : "outline-eye"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<label id={`${id}-${category}`} for={`${id}-${category}-visibility`}>
|
||||
{language.t(`settings.timeline.category.${category}`)}
|
||||
</label>
|
||||
</div>
|
||||
<div data-slot="timeline-detail-placement">
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.group")}
|
||||
</span>
|
||||
<Show
|
||||
when={props.value[category].placement !== "hidden"}
|
||||
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
|
||||
>
|
||||
<Switch
|
||||
data-category={category}
|
||||
data-field="placement"
|
||||
hideLabel
|
||||
checked={props.value[category].placement === "grouped"}
|
||||
onChange={(checked) =>
|
||||
props.onChange({
|
||||
...props.value,
|
||||
[category]: { ...props.value[category], placement: checked ? "grouped" : "separate" },
|
||||
})
|
||||
}
|
||||
>
|
||||
{language.t("settings.timeline.grouped.label", {
|
||||
activity: language.t(`settings.timeline.category.${category}`),
|
||||
})}
|
||||
</Switch>
|
||||
</Show>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-slot="timeline-detail-expansion">
|
||||
{category === "shell" || category === "edit" || category === "thinking" ? (
|
||||
<>
|
||||
<span data-slot="timeline-detail-field-label" aria-hidden="true">
|
||||
{language.t("settings.timeline.collapse")}
|
||||
</span>
|
||||
<Show
|
||||
when={props.value[category].placement !== "hidden"}
|
||||
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
|
||||
>
|
||||
<Switch
|
||||
data-category={category}
|
||||
data-field="details"
|
||||
hideLabel
|
||||
checked={props.value[category].details === "collapsed"}
|
||||
onChange={(checked) =>
|
||||
props.onChange({
|
||||
...props.value,
|
||||
[category]: { ...props.value[category], details: checked ? "collapsed" : "expanded" },
|
||||
})
|
||||
}
|
||||
>
|
||||
{language.t("settings.timeline.collapsed.label", {
|
||||
activity: language.t(`settings.timeline.category.${category}`),
|
||||
})}
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
padding: 0 12px max(12px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
@@ -73,7 +74,9 @@ function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,9 @@ export default function Layout(props: ParentProps) {
|
||||
: platform.platform === "desktop" && platform.os === "windows"
|
||||
? "1px"
|
||||
: "8px",
|
||||
"--shell-bottom-inset": bottomTitlebar() ? "8px" : "max(0px, calc(8px - env(safe-area-inset-bottom, 0px)))",
|
||||
"--shell-bottom-inset": bottomTitlebar()
|
||||
? "8px"
|
||||
: "max(0px, calc(8px - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
@@ -67,7 +69,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
|
||||
style={{
|
||||
width: `${state.tabsWidth}px`,
|
||||
"padding-bottom": "max(10px, env(safe-area-inset-bottom, 0px))",
|
||||
"padding-bottom": "max(10px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))",
|
||||
}}
|
||||
>
|
||||
<ResizeHandle
|
||||
@@ -85,8 +87,13 @@ export default function Layout(props: ParentProps) {
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content"
|
||||
style={{
|
||||
"padding-top": bottomTitlebar() ? "env(safe-area-inset-top, 0px)" : "0px",
|
||||
"padding-bottom": bottomTitlebar() || settings.active() ? "0px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"--settings-bottom-inset": bottomTitlebar() ? "40px" : "env(safe-area-inset-bottom, 0px)",
|
||||
"padding-bottom":
|
||||
bottomTitlebar() || settings.active()
|
||||
? "0px"
|
||||
: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
|
||||
"--settings-bottom-inset": bottomTitlebar()
|
||||
? "40px"
|
||||
: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
|
||||
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
|
||||
@@ -155,11 +155,11 @@ export function Titlebar(props: {
|
||||
height:
|
||||
platform.platform === "web"
|
||||
? bottom()
|
||||
? "calc(28px + max(8px, env(safe-area-inset-bottom, 0px)))"
|
||||
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: undefined,
|
||||
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : "0px",
|
||||
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
|
||||
"min-height": minHeight(),
|
||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
|
||||
@@ -440,7 +440,7 @@ export function Titlebar(props: {
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
|
||||
classList={{
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pb-[max(0px,calc(8px-env(safe-area-inset-bottom,0px)))]": bottom(),
|
||||
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
|
||||
"pl-4": macTrafficLights(),
|
||||
// Center the 20px app icon over the sidebar's 16px icon column.
|
||||
"ps-3.5": windows(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createComposerState, type ComposerStore } from "@/composer/state"
|
||||
import { createComposerEditorActions } from "@/composer/editor/actions"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
|
||||
function setup(read: () => string | null | Promise<string | null> = () => null) {
|
||||
return createRoot((dispose) => {
|
||||
@@ -27,83 +28,72 @@ function setup(read: () => string | null | Promise<string | null> = () => null)
|
||||
})
|
||||
}
|
||||
|
||||
test("composer-write-batch: typing persists prompt, cursor and retry together before returning", async () => {
|
||||
test("composer-write-batch: a burst of edits persists once with prompt, cursor and retry together", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.context.add({ type: "file", path: "src/queue.ts", preview: "await queue.flush()" })
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setPrompt([{ type: "text", content: "keep ordering", start: 0, end: 13 }], 13)
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes).toHaveLength(0)
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0]).toMatchObject({
|
||||
prompt: [{ type: "text", content: "keep ordering", start: 0, end: 13 }],
|
||||
cursor: 13,
|
||||
cursor: 12,
|
||||
context: { items: [{ path: "src/queue.ts", preview: "await queue.flush()" }] },
|
||||
})
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
value.editor.setCursor(13)
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes.map((write) => write.cursor)).toEqual([13, 12])
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes).toHaveLength(2)
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: state replacement preserves an omitted cursor and resets in one write", async () => {
|
||||
test("composer-write-batch: a save with no serialized change writes nothing", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
|
||||
value.state.mode.set("shell")
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setCursor(5)
|
||||
value.state.mode.set("shell")
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(0)
|
||||
value.state.reset()
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: state replacement snapshots the value at save time", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
const prompt = [{ type: "text" as const, content: "next", start: 0, end: 4 }]
|
||||
value.state.set(prompt)
|
||||
prompt[0].content = "changed outside the store"
|
||||
expect(value.state.current()[0]).toMatchObject({ content: "next" })
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "next", start: 0, end: 4 }])
|
||||
expect(value.writes[0].cursor).toBe(5)
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
value.state.reset()
|
||||
expect(value.writes).toHaveLength(2)
|
||||
expect(value.writes[1]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
|
||||
value.state.mode.set("normal")
|
||||
value.state.mode.set("normal")
|
||||
expect(value.writes).toHaveLength(3)
|
||||
expect(value.writes[2].mode).toBe("normal")
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: unchanged mode still clears a retry and context writes remain ordered", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.mode.set("normal")
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
value.writes.length = 0
|
||||
value.editor.setMode("normal")
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
value.state.context.add({ type: "file", path: "first.ts" })
|
||||
value.state.context.add({ type: "file", path: "second.ts" })
|
||||
value.state.context.remove(value.state.context.items()[0].key)
|
||||
expect(value.writes.slice(1).map((write) => write.context.items.map((item) => item.path))).toEqual([
|
||||
["first.ts"],
|
||||
["first.ts", "second.ts"],
|
||||
["second.ts"],
|
||||
])
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: text replacement and insertion each persist once and retain attachments", async () => {
|
||||
test("composer-write-batch: the last edit in a window wins and attachments are retained", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
@@ -120,12 +110,14 @@ test("composer-write-batch: text replacement and insertion each persist once and
|
||||
],
|
||||
3,
|
||||
)
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setText("new")
|
||||
value.editor.addText(" notes")
|
||||
expect(value.writes).toHaveLength(2)
|
||||
expect(value.writes.map((write) => write.cursor)).toEqual([3, 9])
|
||||
expect(value.writes[1].prompt).toEqual([
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].cursor).toBe(9)
|
||||
expect(value.writes[0].prompt).toEqual([
|
||||
{ type: "text", content: "new notes", start: 0, end: 9 },
|
||||
{
|
||||
type: "image",
|
||||
@@ -145,7 +137,6 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
|
||||
const value = setup(() => loading.promise)
|
||||
try {
|
||||
value.editor.setPrompt([{ type: "text", content: "new", start: 0, end: 3 }], 3)
|
||||
expect(value.writes).toHaveLength(1)
|
||||
loading.resolve(
|
||||
JSON.stringify({
|
||||
prompt: [{ type: "text", content: "old", start: 0, end: 3 }],
|
||||
@@ -156,13 +147,15 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
|
||||
await value.state.ready.promise
|
||||
expect(value.state.current()).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
|
||||
expect(value.state.cursor()).toBe(3)
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: persistence still precedes reactive observers", async () => {
|
||||
test("composer-write-batch: observers see every edit before anything is persisted", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
@@ -177,10 +170,22 @@ test("composer-write-batch: persistence still precedes reactive observers", asyn
|
||||
value.editor.addText(" and insert")
|
||||
value.state.set([{ type: "text", content: "restore", start: 0, end: 7 }], 7)
|
||||
value.state.reset()
|
||||
expect(observed).toEqual([0, 1, 2, 3, 4, 5])
|
||||
expect(observed).toEqual([0, 0, 0, 0, 0, 0])
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
dispose()
|
||||
})
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: disposing the owner saves pending edits", async () => {
|
||||
const value = setup()
|
||||
await value.state.ready.promise
|
||||
value.editor.setPrompt([{ type: "text", content: "unsaved", start: 0, end: 7 }], 7)
|
||||
expect(value.writes).toHaveLength(0)
|
||||
value.dispose()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "unsaved", start: 0, end: 7 }])
|
||||
})
|
||||
|
||||
@@ -9,7 +9,10 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
])
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async () => id,
|
||||
getBlob,
|
||||
@@ -113,7 +116,7 @@ test("keeps different blob IDs independent", async () => {
|
||||
const reads: string[] = []
|
||||
const store = createDraftStore({
|
||||
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
|
||||
set: async () => {},
|
||||
set: async () => [],
|
||||
remove: async () => {},
|
||||
putBlob: async () => "unused",
|
||||
getBlob: async (id) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
|
||||
@@ -25,6 +26,7 @@ test("persisted model selection hydrates, updates and serializes the schema shap
|
||||
expect(state.session.session1?.agent).toBe("plan")
|
||||
setState("session", "session1", { agent: "build", variant: null })
|
||||
expect(state.session.session1?.agent).toBe("build")
|
||||
flushPersisted()
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
|
||||
session: { session1: { agent: "build", variant: null } },
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
@@ -39,7 +40,7 @@ describe("prompt persistence", () => {
|
||||
async (raw) => {
|
||||
const store = createDraftStore({
|
||||
get: async () => raw,
|
||||
set: async () => undefined,
|
||||
set: async () => [],
|
||||
remove: async () => undefined,
|
||||
putBlob: async () => "unused",
|
||||
getBlob: async () => null,
|
||||
@@ -67,7 +68,10 @@ describe("prompt persistence", () => {
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async (blob) => {
|
||||
blobs.set("composer-image", blob)
|
||||
@@ -109,6 +113,7 @@ describe("prompt persistence", () => {
|
||||
},
|
||||
])
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
expect(documents.get(key)).toContain("hello")
|
||||
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
|
||||
@@ -168,7 +173,10 @@ describe("prompt persistence", () => {
|
||||
const documents = new Map<string, string>()
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async () => "blob",
|
||||
getBlob: async () => null,
|
||||
@@ -199,7 +207,10 @@ describe("prompt persistence", () => {
|
||||
const documents = new Map<string, string>()
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async () => "blob",
|
||||
getBlob: async () => null,
|
||||
@@ -231,7 +242,10 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async (blob) => {
|
||||
const id = String(blob.size)
|
||||
@@ -253,7 +267,10 @@ test("does not let delayed blob migration overwrite a newer draft", async () =>
|
||||
const migration = Promise.withResolvers<void>()
|
||||
const store = createDraftStore({
|
||||
get: async () => null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
set: async (key, value) => {
|
||||
documents.set(key, value)
|
||||
return []
|
||||
},
|
||||
remove: async () => undefined,
|
||||
putBlob: async () => {
|
||||
await migration.promise
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { TabStorage } from "@/shell/tabs/schema"
|
||||
@@ -57,6 +58,7 @@ describe("schema-backed persistence", () => {
|
||||
expect(state.key).toBe("session-tab")
|
||||
setState("key", undefined)
|
||||
expect(state.key).toBeUndefined()
|
||||
flushPersisted()
|
||||
expect(localStorage.getItem(key)).toBe("{}")
|
||||
} finally {
|
||||
dispose()
|
||||
@@ -74,6 +76,7 @@ describe("schema-backed persistence", () => {
|
||||
expect(state).toEqual({ enabled: true, label: "saved" })
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
|
||||
setState("enabled", false)
|
||||
flushPersisted()
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
|
||||
dispose()
|
||||
})
|
||||
@@ -125,6 +128,7 @@ describe("schema-backed persistence", () => {
|
||||
label: "desktop",
|
||||
})
|
||||
root.state[1]("label", "changed")
|
||||
flushPersisted()
|
||||
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
|
||||
enabled: true,
|
||||
label: "changed",
|
||||
|
||||
@@ -44,6 +44,10 @@ export default Runtime.handler(
|
||||
)
|
||||
const output = yield* Effect.promise(() => response.text())
|
||||
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
|
||||
if (!response.ok) {
|
||||
process.stderr.write(`HTTP ${response.status} ${response.statusText}${EOL}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
||||
import type { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Config } from "@opencode-ai/schema/config"
|
||||
|
||||
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1119,6 +1128,7 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
|
||||
@@ -68,6 +68,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -594,6 +596,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -744,6 +757,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -844,6 +846,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
|
||||
@@ -4138,6 +4138,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
+75
-64
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -308,7 +309,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -317,7 +318,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -331,7 +332,8 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -385,9 +387,7 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,13 +464,7 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -499,19 +493,23 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -519,49 +517,57 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -344,8 +344,13 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/baseten" ||
|
||||
name === "@opencode-ai/ai/providers/cerebras" ||
|
||||
name === "@opencode-ai/ai/providers/cloudflare-ai-gateway" ||
|
||||
name === "@opencode-ai/ai/providers/cloudflare-workers-ai" ||
|
||||
name === "@opencode-ai/ai/providers/deepinfra" ||
|
||||
name === "@opencode-ai/ai/providers/deepseek" ||
|
||||
name === "@opencode-ai/ai/providers/fireworks" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/groq" ||
|
||||
|
||||
@@ -79,6 +79,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode-ai/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -192,6 +193,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
|
||||
@@ -163,11 +163,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined)
|
||||
existing.body = Provider.mergeOverlay(
|
||||
existing.body,
|
||||
variantBody(variant.body, model.package ?? item.package ?? source?.provider.package),
|
||||
)
|
||||
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
if (config.cost !== undefined)
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
@@ -236,18 +232,6 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
}
|
||||
|
||||
function variantBody(body: Readonly<Record<string, unknown>>, packageName: string | undefined) {
|
||||
if (packageName !== Provider.aisdk("@ai-sdk/openai")) return body
|
||||
const { reasoningEffort, reasoningSummary, ...native } = body
|
||||
const reasoning = {
|
||||
...(typeof reasoningEffort === "string" ? { effort: reasoningEffort } : {}),
|
||||
...(typeof reasoningSummary === "string" ? { summary: reasoningSummary } : {}),
|
||||
}
|
||||
if (Object.keys(reasoning).length === 0) return body
|
||||
// Existing Console variants stored SDK options here before V2 consumed raw bodies.
|
||||
return Provider.mergeOverlay({ reasoning }, native)
|
||||
}
|
||||
|
||||
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
|
||||
return (
|
||||
body &&
|
||||
|
||||
@@ -116,15 +116,15 @@ opencode2 mcp list
|
||||
```
|
||||
|
||||
Remote servers use OAuth by default. If `mcp list` reports that a server needs
|
||||
authentication, run the OAuth flow and then verify the connection:
|
||||
authentication, tell the user to run `/mcps`, select the server, and sign in.
|
||||
Do not run `opencode2 mcp auth` through the shell tool: it starts an interactive
|
||||
flow whose authorization link can be hidden in background process output.
|
||||
Use the user-facing MCP interface instead.
|
||||
|
||||
```sh
|
||||
opencode2 mcp auth <name>
|
||||
opencode2 mcp list
|
||||
```
|
||||
Report the server as configured but awaiting sign-in until its connection
|
||||
status confirms it is connected.
|
||||
|
||||
The auth command prints an authorization URL, waits for the browser redirect,
|
||||
and stores credentials outside the OpenCode configuration. Do not ask for or
|
||||
OAuth credentials are stored outside the OpenCode configuration. Do not ask for or
|
||||
store an API key when the server supports OAuth. Use header-based credentials
|
||||
only when OAuth is unavailable or the user explicitly requires them, and use an
|
||||
environment substitution such as `{env:MCP_API_KEY}` instead of writing a
|
||||
|
||||
@@ -48,8 +48,13 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/baseten", () => import("@opencode-ai/ai/providers/baseten")],
|
||||
["@opencode-ai/ai/providers/cerebras", () => import("@opencode-ai/ai/providers/cerebras")],
|
||||
["@opencode-ai/ai/providers/cloudflare-ai-gateway", () => import("@opencode-ai/ai/providers/cloudflare-ai-gateway")],
|
||||
["@opencode-ai/ai/providers/cloudflare-workers-ai", () => import("@opencode-ai/ai/providers/cloudflare-workers-ai")],
|
||||
["@opencode-ai/ai/providers/deepinfra", () => import("@opencode-ai/ai/providers/deepinfra")],
|
||||
["@opencode-ai/ai/providers/deepseek", () => import("@opencode-ai/ai/providers/deepseek")],
|
||||
["@opencode-ai/ai/providers/fireworks", () => import("@opencode-ai/ai/providers/fireworks")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
|
||||
@@ -57,8 +57,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -113,6 +116,7 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -142,6 +146,13 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -230,6 +241,7 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -362,6 +374,17 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -450,6 +473,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by a turn: the steps that follow a user message up to
|
||||
* the next user message, so a steer starts its own turn. `to` extends the range
|
||||
* through a later user message's turn. Compares the range's first recorded
|
||||
* start snapshot with its last recorded end snapshot; only a step still running
|
||||
* in the active Session compares against the working copy. Like VCS diffs, an
|
||||
* omitted `context` yields full-file patches.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const users = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
eq(SessionMessageTable.type, "user"),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = users.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const start = input.messageID
|
||||
? yield* resolve("messageID", input.messageID)
|
||||
: users.findLast((row) => row.type === "user")
|
||||
if (!start) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : start
|
||||
if (last.seq < start.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${start.id}` })
|
||||
const next = users.find((row) => row.type === "user" && row.seq > last.seq)
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start.seq),
|
||||
next ? lt(SessionMessageTable.seq, next.seq) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -131,38 +131,55 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
return {
|
||||
source: repo.source,
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: input.paths,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -92,18 +92,15 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||
const written = yield* FileMutation.readText(environment.files, target.absolute)
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
|
||||
: written.text
|
||||
return {
|
||||
output: result,
|
||||
content: toModelContent(result),
|
||||
metadata: {
|
||||
files: [fileDiff(result.resource, current?.text ?? "", formatted, current ? "modified" : "added")],
|
||||
},
|
||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) {
|
||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelContent(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { VcsPatch } from "@opencode-ai/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -196,6 +197,42 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -132,6 +132,25 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps explicitly selected compatible packages generic for known provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const providerID of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks-ai", "groq", "togetherai"]) {
|
||||
const selected = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
providerID: Provider.ID.make(providerID),
|
||||
settings: { baseURL: "https://provider.example/v1/openai", apiKey: "fixture" },
|
||||
}),
|
||||
)
|
||||
expect(String(selected.provider)).toBe(providerID)
|
||||
expect(selected.route.id).toBe("openai-compatible-chat")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://provider.example/v1/openai")
|
||||
const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" }))
|
||||
expect(prepared.body.messages).toEqual([{ role: "user", content: "Hello" }])
|
||||
expect(prepared.body).not.toHaveProperty("apiKey")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves environment templates before native providers inspect endpoints", () =>
|
||||
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -301,6 +320,13 @@ describe("ModelResolver", () => {
|
||||
settings: { baseURL: "https://native-mistral.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
}),
|
||||
...["baseten", "cloudflare-ai-gateway", "cloudflare-workers-ai", "deepseek", "fireworks"].map((name) =>
|
||||
model(`@opencode-ai/ai/providers/${name}`, {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: `https://${name}.example.com/v1` },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
}),
|
||||
),
|
||||
]
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(selected.providerID),
|
||||
|
||||
@@ -380,40 +380,23 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes legacy Console OpenAI variant bodies without changing native bodies", () =>
|
||||
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const variants = [
|
||||
{
|
||||
id: "legacy",
|
||||
body: { reasoningEffort: "high", reasoningSummary: "auto" },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "effort-only",
|
||||
body: { reasoningEffort: "low" },
|
||||
expected: { reasoning: { effort: "low" } },
|
||||
body: { reasoning: { effort: "low" } },
|
||||
},
|
||||
{
|
||||
id: "summary-only",
|
||||
body: { reasoningSummary: "detailed" },
|
||||
expected: { reasoning: { summary: "detailed" } },
|
||||
body: { reasoning: { summary: "detailed" } },
|
||||
},
|
||||
{
|
||||
id: "native",
|
||||
body: { reasoning: { effort: "high", summary: "auto" } },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "mixed",
|
||||
body: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { effort: "high", summary: "detailed" },
|
||||
},
|
||||
expected: { reasoning: { effort: "high", summary: "detailed" } },
|
||||
},
|
||||
{ id: "plain", body: {}, expected: {} },
|
||||
{ id: "plain", body: {} },
|
||||
]
|
||||
const models = {
|
||||
astra: {
|
||||
@@ -481,7 +464,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const projected = required(model.variants.find((item) => item.id === variant.id))
|
||||
expect(projected.body).toEqual({
|
||||
...variant.expected,
|
||||
...variant.body,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
@@ -495,7 +478,7 @@ describe("OpencodePlugin", () => {
|
||||
const request = required(requests.at(-1))
|
||||
expect(request.variant).toBe(variant.id)
|
||||
expect(request.body).toMatchObject({
|
||||
...variant.expected,
|
||||
...variant.body,
|
||||
model: "api-astra",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
@@ -508,9 +491,8 @@ describe("OpencodePlugin", () => {
|
||||
const compatible = required(yield* catalog.model.get(Provider.ID.make("compatible"), Model.ID.make("astra")))
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
for (const model of [compatible, override]) {
|
||||
expect(model.variants.find((variant) => variant.id === "legacy")?.body).toEqual({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
expect(model.variants.find((variant) => variant.id === "native")?.body).toEqual({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
|
||||
@@ -5,8 +5,13 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
describe("Provider", () => {
|
||||
test("loads bundled native provider entrypoints", async () => {
|
||||
const packages = [
|
||||
"@opencode-ai/ai/providers/baseten",
|
||||
"@opencode-ai/ai/providers/cerebras",
|
||||
"@opencode-ai/ai/providers/cloudflare-ai-gateway",
|
||||
"@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||
"@opencode-ai/ai/providers/deepinfra",
|
||||
"@opencode-ai/ai/providers/deepseek",
|
||||
"@opencode-ai/ai/providers/fireworks",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionDiff } from "@opencode-ai/core/session/diff"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the steps that follow a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
// A steer is a new prompt, so it starts a new turn.
|
||||
const steer = yield* prompt("Also add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: second })).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: second, to: steer })).toEqual([
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: steer })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later turns; a fork keeps the copied ones.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -119,17 +119,6 @@ describe("WriteTool", () => {
|
||||
existed: false,
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
|
||||
"created",
|
||||
@@ -169,20 +158,6 @@ describe("WriteTool", () => {
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "formatted.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+FORMAT ME"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [{ patch: expect.stringContaining("+format me") }],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||
}),
|
||||
@@ -205,7 +180,6 @@ describe("WriteTool", () => {
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(settled.metadata).toEqual(fixture.assertions[0]?.metadata)
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
@@ -241,22 +215,7 @@ describe("WriteTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "preserved.txt", content: "after" }, "call-preserved"),
|
||||
)
|
||||
expect(settled).toMatchObject({
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "preserved.txt",
|
||||
status: "modified",
|
||||
patch: expect.stringMatching(/-before[\s\S]*\+after/),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
if (settled.status === "completed") expect(JSON.stringify(settled.metadata)).not.toContain("\uFEFF")
|
||||
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
|
||||
@@ -271,27 +230,6 @@ describe("WriteTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reports zero-change metadata for empty and unchanged writes", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
return withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-empty"))
|
||||
expect(created).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "added", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
const unchanged = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-unchanged"))
|
||||
expect(unchanged).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "modified", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "empty.txt"), "utf8"))).toBe("")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user