Compare commits

..

11 Commits

Author SHA1 Message Date
Brendan Allan 55874ccaeb simplify repalceServerConnection 2026-08-07 19:34:47 +08:00
Brendan Allan b8ed70d6d3 refactor(app): split server management controllers 2026-08-07 17:37:35 +08:00
Aiden Cline 2092350cfa fix(core): align shell output limits (#41007) 2026-08-07 00:18:16 -05:00
Aiden Cline 5fb0d7c99c feat(core): bound tool output (#40929) 2026-08-07 00:02:14 -05:00
Brendan Allan a1b4843a33 refactor(app): remove legacy layout (#40947) 2026-08-07 11:35:04 +08:00
Kit Langton cc7827fe08 refactor(core): simplify file tools to lexical paths (#40962) 2026-08-06 21:53:52 -04:00
opencode-agent[bot] 76e4d88d21 fix(core): default custom agents to primary (#40880)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:47:51 -05:00
opencode-agent[bot] 439ed66c7b fix(core): migrate legacy small model (#40966)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:46:12 -05:00
Kit Langton 047d434aa2 fix(tui): dismiss stale permission prompts (#40960) 2026-08-06 21:43:44 -04:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
162 changed files with 3303 additions and 12305 deletions
-40
View File
@@ -678,10 +678,8 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"modal": "0.9.0",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -1513,18 +1511,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1733,10 +1719,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1831,8 +1813,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -3317,8 +3297,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3559,10 +3537,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4595,8 +4569,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4823,8 +4795,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4863,10 +4833,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5665,8 +5631,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6625,8 +6589,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6771,8 +6733,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -1,19 +1,18 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
const draftID = "draft_legacy_new_session" const draftID = "draft_removed_layout_preference"
const directory = "C:/OpenCode/LegacyNewSession" const directory = "C:/OpenCode/RemovedLayoutPreference"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("redirects a draft to the legacy new-session route", async ({ page }) => { test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: { project: {
id: "proj_legacy_new_session", id: "proj_removed_layout_preference",
worktree: directory, worktree: directory,
vcs: "git", vcs: "git",
name: "legacy-new-session", name: "removed-layout-preference",
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [], sandboxes: [],
}, },
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.addInitScript( await page.addInitScript(
({ directory, draftID, server }) => { ({ directory, draftID, server }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
localStorage.setItem( localStorage.setItem(
"opencode.window.browser.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]), JSON.stringify([{ type: "draft", draftID, server, directory }]),
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
await page.goto(`/new-session?draftId=${draftID}`) await page.goto(`/new-session?draftId=${draftID}`)
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`) await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible() await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
}) })
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep"> <body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div> <div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+31 -146
View File
@@ -14,14 +14,11 @@ import {
Navigate, Navigate,
Route, Route,
Router, Router,
useLocation,
useNavigate,
useParams, useParams,
useSearchParams, useSearchParams,
} from "@solidjs/router" } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { import {
type Component, type Component,
createEffect, createEffect,
@@ -43,7 +40,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -54,58 +51,36 @@ import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout" import Layout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage" import { decode64 } from "@/utils/base64"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home" import { Home } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = () => { const DirectoryDraftRedirect = () => {
const settings = useSettings()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
const sessionID = params.id
return (
<Show when={tabs.ready()}>
{(_) => {
const persisted = tabs.store.filter((item) => item.type === "session")
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
}}
</Show>
)
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
if (!settings.general.newLayoutDesigns()) return if (search.draftId || !tabs.ready()) return
if (params.id || search.draftId) return const directory = decode64(params.dir)
if (!tabs.ready() || !sdk().directory) return if (!directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) tabs.newDraft({ server: server.key, directory }, search.prompt)
}) })
return ( return null
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
} }
function TargetServerRoute(props: ParentProps) { function TargetServerRoute(props: ParentProps) {
@@ -117,9 +92,7 @@ function TargetServerRoute(props: ParentProps) {
}) })
return ( return (
// Owns the server-identity remount. Session changes must NOT remount this // Owns the server-identity remount. Session changes must not remount this subtree.
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider> <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
@@ -134,35 +107,6 @@ const TargetSessionRoute = () => (
</TargetServerRoute> </TargetServerRoute>
) )
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.location.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server. // server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
@@ -175,17 +119,8 @@ function SelectedServerProviders(props: ParentProps) {
) )
} }
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
@@ -194,14 +129,7 @@ function DraftRoute() {
keyed keyed
fallback={<Navigate href="/" />} fallback={<Navigate href="/" />}
> >
{(draft) => ( {(draft) => <ResolvedDraftRoute draft={draft} />}
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
>
<ResolvedDraftRoute draft={draft} />
</Show>
)}
</Show> </Show>
</Show> </Show>
) )
@@ -237,10 +165,6 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
} }
function LayoutCompatibility(props: ParentProps) {
return <>{props.children}</>
}
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
@@ -267,17 +191,11 @@ function QueryProvider(props: ParentProps) {
} }
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings()
createRenderEffect(() => { createRenderEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
document.body.toggleAttribute("data-new-layout", true)
const enabled = settings.general.newLayoutDesigns() document.body.classList.remove("text-12-regular")
document.body.toggleAttribute("data-new-layout", enabled) document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
}) })
return null return null
@@ -320,7 +238,6 @@ function DesktopCommands() {
return null return null
} }
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
serverScoped?: JSX.Element serverScoped?: JSX.Element
@@ -335,19 +252,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) { function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return ( return (
<SelectedServerProviders> <SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}> <ServerScopedProviders serverScoped={props.serverScoped}>
<NewLayout>{props.children}</NewLayout> <Layout>{props.children}</Layout>
</ServerScopedProviders> </ServerScopedProviders>
</SelectedServerProviders> </SelectedServerProviders>
) )
@@ -536,7 +445,7 @@ export function AppInterface(props: {
startup?: Promise<void> startup?: Promise<void>
serverScoped?: JSX.Element serverScoped?: JSX.Element
}) { }) {
// The visual new layout lives in the router root so it remains mounted across // The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data // route changes. Draft and session routes override only their server-bound data
// providers beneath it. // providers beneath it.
const ServerShell = (shellProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
@@ -557,7 +466,6 @@ export function AppInterface(props: {
<GlobalProvider> <GlobalProvider>
<SettingsProvider> <SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}> <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
<Dynamic <Dynamic
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
@@ -565,18 +473,15 @@ export function AppInterface(props: {
<PermissionProvider> <PermissionProvider>
<NotificationProvider> <NotificationProvider>
<ServerShell> <ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell> </ServerShell>
</NotificationProvider> </NotificationProvider>
</PermissionProvider> </PermissionProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
<Routes serverScoped={props.serverScoped} /> <Routes />
</Dynamic> </Dynamic>
</Show>
</ConnectionGate> </ConnectionGate>
</SettingsProvider> </SettingsProvider>
</GlobalProvider> </GlobalProvider>
@@ -584,40 +489,20 @@ export function AppInterface(props: {
) )
} }
function Routes(props: { serverScoped?: JSX.Element }) { function Routes() {
const settings = useSettings()
return ( return (
<> <>
<Route <Route path="/" component={Home} />
component={(routeProps) => ( <Route path="/:dir" component={DirectoryDraftRedirect} />
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout> <Route path="/:dir/session" component={DirectoryDraftRedirect} />
)} <Route path="/:dir/session/:id" component={LegacySessionRedirect} />
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} /> <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} /> <Route path="/new-session" component={DraftRoute} />
</> </>
) )
} }
function NewLayoutLegacySessionRedirect() { function LegacySessionRedirect() {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

@@ -1,170 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { For, Show } from "solid-js"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { createEditProjectModel } from "./edit-project"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
/>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!model.store.iconOverride,
}}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
class="size-full text-[32px]"
/>
</div>
}
>
{(src) => (
<img
src={src()}
alt={language.t("dialog.project.edit.icon.alt")}
class="size-full object-cover"
/>
)}
</Show>
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input
id="icon-upload"
ref={(el) => {
model.setIconInput(el)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={model.store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
model.store.color !== color,
}}
onClick={() => {
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
}}
>
<Avatar
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<TextField
multiline
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -6,21 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { Show } from "solid-js"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import {
type ServerDomainController,
const DEFAULT_USERNAME = "opencode" type ServerFormController,
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -39,76 +35,6 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() { export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close }) const language = useLanguage()
const domain = useServerDomainController({ onSelect: () => dialog.close() })
const form = useServerFormController({ onSelect: () => dialog.close() })
const title = () => {
if (!form.state.open()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
<span>
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
</span>
</div>
)
}
return ( return (
<Dialog title={controller.formTitle()}> <Dialog title={title()}>
<div class="flex flex-1 min-h-0 flex-col px-5"> <div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}> <Show
<ServerConnectionForm controller={controller} /> when={form.state.open()}
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show> </Show>
</div> </div>
</Dialog> </Dialog>
) )
} }
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { export function ServerConnectionList(props: {
const navigate = useNavigate() domain: ServerDomainController
const server = useServer() onAdd: () => void
const tabs = useTabs() onEdit: (server: ServerConnection.Http) => void
const global = useGlobal() }) {
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}} }}
divider={true} divider={true}
> >
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.controller.status()[key]} status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) props.onEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
} }
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) { export function ServerConnectionForm(props: { form: ServerFormController }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.controller.formValue()} value={props.form.state.value()}
name={props.controller.formName()} name={props.form.state.name()}
username={props.controller.formUsername()} username={props.form.state.username()}
password={props.controller.formPassword()} password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()} busy={props.form.state.busy()}
error={props.controller.formError()} error={props.form.state.error()}
status={props.controller.formStatus()} status={props.form.state.status()}
onChange={props.controller.handleFormChange()} onChange={props.form.change.value}
onNameChange={props.controller.handleFormNameChange()} onNameChange={props.form.change.name}
onUsernameChange={props.controller.handleFormUsernameChange()} onUsernameChange={props.form.change.username}
onPasswordChange={props.controller.handleFormPasswordChange()} onPasswordChange={props.form.change.password}
onSubmit={props.controller.submitForm} onSubmit={props.form.submit}
onBack={props.controller.resetForm} onBack={props.form.reset}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.controller.submitForm} onClick={props.form.submit}
disabled={props.controller.formBusy()} disabled={props.form.state.busy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.controller.formBusy() {props.form.state.busy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : props.form.state.adding()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -1,94 +0,0 @@
import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
}
return (
<Dialog size="x-large" transition>
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="h-full settings-dialog"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="general">
<Icon name="sliders" />
{language.t("settings.tab.general")}
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
-148
View File
@@ -1,148 +0,0 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { createSignal, Show } from "solid-js"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={settings.general.shouldDisplayTabsToast()}>
<div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={settings.general.dismissTabsToast}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
settings.general.dismissTabsToast()
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
Organize your work and active sessions with tabs
</p>
</div>
</button>
</div>
</Show>
<DrawerContent
style={
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs
</p>
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<p>OpenCode Desktop is now built around tabs.</p>
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
you're starting something new, and close it when you're done.
</p>
<p>
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
memorable if you plan to keep them around.
</p>
<p>
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>
</Drawer>
)
}
@@ -0,0 +1,323 @@
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
function useServerMutations() {
const server = useServer()
const tabs = useTabs()
return {
add: (connection: ServerConnection.Http) => server.add(connection),
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add: (connection) => server.add(connection),
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
}),
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const language = useLanguage()
const mutations = useServerMutations()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) mutations.add(connection)
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
if (options.navigateOnAdd === false) {
mutations.add(connection)
options.onSelect?.()
return
}
mutations.add(connection)
options.onSelect?.()
navigate("/")
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
export type ServerFormController = ReturnType<typeof useServerFormController>
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController> domain: ServerActionsController
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()} canDefault={props.domain.defaults.available()}
isDefault={props.controller.defaultKey() === key} isDefault={props.domain.defaults.key() === key}
canRemove={props.controller.canRemove(key)} canRemove={props.domain.connection.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)} onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.controller.setDefault(null)} onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.controller.handleRemove(key)} onRemove={() => props.domain.connection.remove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -1,793 +0,0 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
type ShellSelectOption = {
id: string
value: string
label: string
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const params = useParams()
const settings = useSettings()
const updater = useUpdaterAction()
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const dir = createMemo(() => decode64(params.dir))
const accepting = createMemo(() => {
const value = dir()
if (!value) return false
if (!params.id) return permission.isAutoAcceptingDirectory(value)
return permission.isAutoAccepting(params.id, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
permission.toggleAutoAcceptDirectory(value)
return
}
if (checked) {
permission.enableAutoAccept(params.id, value)
return
}
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource(
async () => {
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
() => (linux() && platform.getDisplayBackend ? true : false),
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onDisplayBackendChange = (checked: boolean) => {
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
if (!update) return
void update.finally(() => {
void refetchDisplayBackend()
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const InterfaceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
<span class="flex items-center gap-2">
{language.t("settings.general.row.newInterface.title")}
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
</span>
}
description={language.t("settings.general.row.newInterface.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (!checked) return
void import("@/components/settings-v2").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
disabled
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
// TODO: Restore config writes when the V2 client exposes a config API.
// void serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-ui-font"
label={language.t("settings.general.row.uiFont.title")}
hideLabel
type="text"
value={sans()}
onChange={(value) => settings.appearance.setUIFont(value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-code-font"
label={language.t("settings.general.row.font.title")}
hideLabel
type="text"
value={mono()}
onChange={(value) => settings.appearance.setFont(value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextField
data-action="settings-terminal-font"
label={language.t("settings.general.row.terminalFont.title")}
hideLabel
type="text"
value={terminal()}
onChange={(value) => settings.appearance.setTerminalFont(value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="text-12-regular"
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRow>
</SettingsList>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div>
</div>
<div class="flex flex-col gap-8 w-full">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />
<NotificationsSection />
<SoundsSection />
<UpdatesSection />
<DisplaySection />
<Show when={desktop()}>
<AdvancedSection />
</Show>
</div>
</div>
)
}
interface SettingsRowProps {
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
</div>
)
}
@@ -1,149 +0,0 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
return (
<SettingsServerScope>
<SettingsModelsContent />
</SettingsServerScope>
)
}
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<SettingsServerPicker />
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={list.filter()}
onChange={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={list.filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<Show
when={!list.grouped.loading}
fallback={
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<SettingsList>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</div>
)
}}
</For>
</SettingsList>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
)
}
@@ -1,259 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
return (
<SettingsServerScope>
<SettingsProvidersContent onBack={props.onBack} />
</SettingsServerScope>
)
}
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
}
const connected = createMemo(() => {
return providers
.connected()
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
.popular()
.filter((p) => !connectedIDs.has(p.id))
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
return items
})
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disableProvider = async (providerID: string, name: string) => {
return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => {
await serverSDK()
.api.integration.get({ integrationID: providerID })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
<SettingsServerPicker />
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<Show
when={connected().length > 0}
fallback={
<div class="py-4 text-14-regular text-text-weak">
{language.t("settings.providers.connected.empty")}
</div>
}
>
<For each={connected()}>
{(item) => (
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex items-center gap-3 min-w-0">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
<Tag>{type(item)}</Tag>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
)}
</For>
</Show>
</SettingsList>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<For each={popular()}>
{(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3">
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{item.name}</span>
<Show when={item.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={item.id === "opencode-go"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
{language.t("common.connect")}
</Button>
</div>
)}
</For>
<Show when={false}>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</Show>
</SettingsList>
<Button
variant="ghost"
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
onClick={() => connect()}
>
{language.t("dialog.provider.viewAll")}
</Button>
</div>
</div>
</div>
)
}
@@ -1,106 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { QueryClientProvider } from "@tanstack/solid-query"
import { createMemo, For, type ParentProps, Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { ModelsProvider } from "@/context/models"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
export function SettingsServerScope(props: ParentProps) {
const global = useGlobal()
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
<Show when={global.settings.server.selected()}>
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
</Show>
</Show>
)
}
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
const global = useGlobal()
const serverCtx = () => global.ensureServerCtx(props.server)
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={() => props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryClientProvider>
)
}
export function SettingsServerPicker() {
const global = useGlobal()
const settings = useSettings()
const selected = createMemo(() =>
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
)
return (
<Show when={selected()}>
{(conn) => (
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={Button}
variant="secondary"
size="large"
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
>
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
<ServerRow
conn={conn()}
status={global.servers.health[ServerConnection.key(conn())]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="hidden"
/>
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
<DropdownMenu.RadioGroup
value={global.settings.server.key}
onChange={(key) => {
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
}}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={item}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 min-w-0 flex-1"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
/>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)
}}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)}
</Show>
)
}
@@ -1,33 +0,0 @@
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<Show
when={controller.isFormMode()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
</div>
</Show>
</div>
</div>
)
}
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerFormController } from "../server/server-management-controller"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController({ const form = useServerFormController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false, navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") controller.startAdd() if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) controller.startEdit(props.server) if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
controller.resetForm() form.reset()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (controller.isFormMode()) return if (form.state.open()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
controller.submitForm() form.submit()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking") if (form.state.busy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formValue()} value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()} invalid={!!form.state.error()}
disabled={controller.formBusy()} disabled={form.state.busy()}
autofocus autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={controller.formError()}> <Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span> <span class="settings-v2-server-dialog-error">{form.state.error()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formName()} value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formUsername()} value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formPassword()} value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}> <ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -5,7 +5,6 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -27,7 +26,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link" import { Link } from "../link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import "./settings-v2.css" import "./settings-v2.css"
let demoSoundState = { let demoSoundState = {
@@ -87,7 +85,6 @@ export const SettingsGeneralV2: Component<{
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync() const serverSync = useServerSync()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
@@ -225,31 +222,6 @@ export const SettingsGeneralV2: Component<{
}, },
}) })
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
badge={language.t("settings.general.row.newInterface.badge")}
description={language.t("settings.general.row.newInterface.description")}
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => {
settings.general.setNewLayoutDesigns(checked)
if (checked) return
void import("@/components/dialog-settings").then((module) => {
void dialog.show(() => <module.DialogSettings />)
})
}}
/>
)
const InterfaceNoticeSection = () => (
<LayoutRetirementNotice
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <SettingsListV2>
@@ -689,14 +661,6 @@ export const SettingsGeneralV2: Component<{
</div> </div>
<div class="settings-v2-tab-body"> <div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection /> <GeneralSection />
<AppearanceSection /> <AppearanceSection />
@@ -1,76 +0,0 @@
// @ts-nocheck
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
const copy = {
title: "New layout",
badge: "New",
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
noticeTitle: "You're now using new layout",
noticeDescription: "The previous layout is no longer available",
dismiss: "Dismiss",
}
function Frame(props) {
return <div class="w-[640px] max-w-full">{props.children}</div>
}
function ToggleExample(props) {
const [state, setState] = createStore({ checked: props.checked })
return (
<Frame>
<LayoutTransitionToggle
title={copy.title}
badge={copy.badge}
description={copy.description}
checked={state.checked}
onChange={(checked) => setState("checked", checked)}
/>
</Frame>
)
}
function NoticeExample() {
const [state, setState] = createStore({ dismissed: false })
return (
<Frame>
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
<LayoutRetirementNotice
title={copy.noticeTitle}
description={copy.noticeDescription}
dismiss={copy.dismiss}
onDismiss={() => setState("dismissed", true)}
/>
</Show>
</Frame>
)
}
export default {
title: "App/Settings/Layout transition",
id: "app-settings-layout-transition",
component: LayoutTransitionToggle,
}
export const NewLayoutEnabled = {
render: () => <ToggleExample checked />,
}
export const PreviousLayoutEnabled = {
render: () => <ToggleExample checked={false} />,
}
export const PreviousLayoutRetired = {
render: () => <NoticeExample />,
}
export const AllStates = {
render: () => (
<div class="flex flex-col gap-8">
<ToggleExample checked />
<ToggleExample checked={false} />
<NoticeExample />
</div>
),
}
@@ -1,54 +0,0 @@
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
export function LayoutTransitionToggle(props: {
title: string
badge: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<div class="settings-v2-section">
<div class="settings-v2-interface-feature">
<SettingsListV2>
<SettingsRowV2
title={
<span class="flex items-center gap-2">
{props.title}
<Tag variant="accent">{props.badge}</Tag>
</span>
}
description={props.description}
>
<div data-action="settings-new-layout-designs">
<Switch checked={props.checked} onChange={props.onChange} />
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
}
export function LayoutRetirementNotice(props: {
title: string
description: string
dismiss: string
onDismiss: () => void
}) {
return (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2 title={props.title} description={props.description}>
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
{props.dismiss}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
}
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController() const domain = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item)) const items = domain.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />) void dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />) void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} /> <WslServerSettings domain={domain} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => controller.status()[key] const health = () => domain.collection.health()[key]
const isDefault = () => controller.defaultKey() === key const isDefault = () => domain.defaults.key() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}> <Show when={domain.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} /> <ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
-79
View File
@@ -1,79 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
hasExistingWebState,
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
newLayoutDesignsDefault,
nextSunsetCheckDelay,
resolveNewLayoutDesigns,
shouldDisplayTabsToast,
shouldEnableNewLayout,
} from "./settings"
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
})
test("hides the transition until a sunset is scheduled", () => {
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
})
test("existing profiles can switch before sunset", () => {
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
})
test("classifies web profiles from existing settings or a recorded version", () => {
expect(hasExistingWebState("{}", undefined)).toBe(true)
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
expect(hasExistingWebState(null, undefined)).toBe(false)
})
test("preserves explicit and default layout preferences", () => {
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
})
test("sunset replaces the toggle with a dismissible notice", () => {
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
})
test("caps checks for sunsets beyond the browser timeout limit", () => {
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
})
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
})
test("enables the new layout when no previous version was recorded", () => {
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
})
test("detects upgrades only when a previous version is older", () => {
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
})
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
})
test("does not enable the new layout without a qualifying upgrade", () => {
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
})
})
+7 -199
View File
@@ -1,8 +1,7 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
export interface NotificationSettings { export interface NotificationSettings {
agent: boolean agent: boolean
@@ -34,10 +33,6 @@ export interface Settings {
editToolPartsExpanded: boolean editToolPartsExpanded: boolean
showCustomAgents: boolean showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom" mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
} }
appearance: { appearance: {
fontSize: number fontSize: number
@@ -56,75 +51,6 @@ export interface Settings {
export const monoDefault = "System Mono" export const monoDefault = "System Mono"
export const sansDefault = "System Sans" export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono" export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export const newLayoutDesignsDefault = true
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
export const oldInterfaceSunset = new Date(2026, 8, 14)
const newLayoutDesignsUpgradeCutoff = "1.17.19"
function compareVersions(a: string, b: string) {
const parse = (version: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
if (!match) return
return match.slice(1).map(Number)
}
const left = parse(a)
const right = parse(b)
if (!left || !right) return
const index = left.findIndex((part, index) => part !== right[index])
return index === -1 ? 0 : left[index]! - right[index]!
}
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
if (!previous || !current) return false
const comparison = compareVersions(current, previous)
return comparison !== undefined && comparison > 0
}
export function shouldDisplayTabsToast(
previous: string | undefined,
current: string | undefined,
existingInstall: boolean,
) {
return isAppUpgrade(previous, current) || (!previous && existingInstall)
}
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
return settings !== null || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
if (!previous) return currentComparison !== undefined && currentComparison > 0
if (!isAppUpgrade(previous, current)) return false
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
return (
previousComparison !== undefined &&
currentComparison !== undefined &&
previousComparison <= 0 &&
currentComparison > 0
)
}
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return {
available: scheduled && eligible && !retired,
notice: scheduled && eligible && retired && !dismissed,
}
}
export const maximumSunsetTimeout = 2_147_483_647
export function nextSunsetCheckDelay(sunset: number, now: number) {
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
}
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
if (retired) return true
return preference ?? fallback
}
const monoFallback = const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings", name: "Settings",
gate: false, gate: false,
init: () => { init: () => {
const platform = usePlatform() const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
)
const [launchState, setLaunchState] = createStore({
classified: false,
migrationApplied: false,
previous: undefined as string | undefined,
})
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus) const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
() => store.general?.showCustomAgents, () => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents, defaultSettings.general.showCustomAgents,
) )
const sunset = oldInterfaceSunset
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
const layoutUpgrade = createMemo(() =>
launchState.classified && !launchState.migrationApplied
? shouldEnableNewLayout(launchState.previous, platform.version)
: false,
)
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
legacyNewLayoutDesignsDefault,
)
}
return resolveNewLayoutDesigns(
oldInterfaceRetired(),
store.general?.newLayoutDesigns,
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const checkSunset = () => {
if (Date.now() >= sunset.getTime()) {
setOldInterfaceRetired(true)
return
}
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
}
checkSunset()
onCleanup(() => {
if (timeout.current !== undefined) clearTimeout(timeout.current)
})
}
createEffect(() => {
if (!launchReady() || launchState.classified) return
setLaunchState({
classified: true,
previous: launch.version,
})
if (!platform.version || launch.version === platform.version) return
setLaunch("version", platform.version)
})
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return
if (layoutTransitionClassified()) return
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
})
createEffect(() => {
if (!ready() || !launchState.classified || launchState.migrationApplied) return
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
setStore("general", "newLayoutDesigns", true)
}
setLaunchState("migrationApplied", true)
})
createEffect(() => {
if (!ready() || !launchState.classified) return
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
if (!launchState.previous && !layoutTransitionClassified()) return
setStore(
"general",
"shouldDisplayTabsToast",
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
)
})
createEffect(() => {
if (!ready() || !oldInterfaceRetired()) return
if (store.general?.newLayoutDesigns === true) return
setStore("general", "newLayoutDesigns", true)
})
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const root = document.documentElement const root = document.documentElement
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setMobileTitlebarPosition(value: "top" | "bottom") { setMobileTitlebarPosition(value: "top" | "bottom") {
setStore("general", "mobileTitlebarPosition", value) setStore("general", "mobileTitlebarPosition", value)
}, },
newLayoutDesigns, newLayoutDesigns: () => true,
setNewLayoutDesigns(value: boolean) {
const next = oldInterfaceRetired() ? true : value
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
layoutTransitionClassified,
setOldLayoutEligible(eligible: boolean) {
const current = store.general?.layoutTransitionEligible
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true)
},
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
dismissTabsToast() {
setStore("general", "shouldDisplayTabsToast", false)
},
}, },
visibility: { visibility: {
fileTree: visible(showFileTree), fileTree: showFileTree,
search: visible(showSearch), search: showSearch,
status: visible(showStatus), status: showStatus,
customAgents: visible(showCustomAgents), customAgents: showCustomAgents,
}, },
appearance: { appearance: {
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
+1 -1
View File
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
import { createHomeSessionsController } from "./home/home-sessions-controller" import { createHomeSessionsController } from "./home/home-sessions-controller"
import { HomeSessions } from "./home/home-sessions" import { HomeSessions } from "./home/home-sessions"
export function NewHome() { export function Home() {
const home = createHomeController() const home = createHomeController()
const projects = createHomeProjectsController(home) const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home) const sessions = createHomeSessionsController(home)
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server" import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false }) const serverManagement = useServerActionsController()
const [_state, setState, _, ready] = persisted( const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]), Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key]) setState("collapsed", key, !state().collapsed[key])
}, },
canDefault: serverManagement.canDefault, canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaultKey, defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) => setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />), edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer, focus: home.selection.focusServer,
}, },
-142
View File
@@ -1,142 +0,0 @@
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { type ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Logo } from "@opencode-ai/ui/logo"
import { useNavigate } from "@solidjs/router"
import { DateTime } from "luxon"
import { createMemo, For, Match, Switch } from "solid-js"
export function LegacyHome() {
const sync = useServerSync()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync().data.path.home)
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
const recent = createMemo(() => {
return sync()
.data.project.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
const serverDotClass = createMemo(() => {
const healthy = global.servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
function openProject(conn: ServerConnection.Any, directory: string) {
const serverCtx = global.ensureServerCtx(conn)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
function chooseProject() {
if (serverUnreachable()) return
const conn = server.current
if (!conn) return
const resolve = (result: string | string[] | null) => {
if (Array.isArray(result)) {
result.forEach((directory) => openProject(conn, directory))
return
}
if (result) openProject(conn, result)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={sync().data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button
icon="folder-add-left"
size="normal"
class="pl-2 pr-3"
disabled={serverUnreachable()}
onClick={chooseProject}
>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(server.current!, project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync().ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
</div>
)
}
-53
View File
@@ -1,53 +0,0 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
const [state, setState] = createStore({ debugTools: true })
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
<TabsInfoPopup />
<ToastRegion v2 />
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,126 +0,0 @@
import { createStore } from "solid-js/store"
import { onCleanup, Show, type Accessor } from "solid-js"
import { InlineInput } from "@opencode-ai/ui/inline-input"
export function createInlineEditorController() {
// This controller intentionally supports one active inline editor at a time.
const [editor, setEditor] = createStore({
active: "" as string,
value: "",
})
const editorOpen = (id: string) => editor.active === id
const editorValue = () => editor.value
const openEditor = (id: string, value: string) => {
if (!id) return
setEditor({ active: id, value })
}
const closeEditor = () => setEditor({ active: "", value: "" })
const saveEditor = (callback: (next: string) => void) => {
const next = editor.value.trim()
if (!next) {
closeEditor()
return
}
closeEditor()
callback(next)
}
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
if (event.key === "Enter") {
event.preventDefault()
saveEditor(callback)
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
}
const InlineEditor = (props: {
id: string
value: Accessor<string>
onSave: (next: string) => void
class?: string
displayClass?: string
editing?: boolean
stopPropagation?: boolean
openOnDblClick?: boolean
}) => {
let frame: number | undefined
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
const isEditing = () => props.editing ?? editorOpen(props.id)
const stopEvents = () => props.stopPropagation ?? false
const allowDblClick = () => props.openOnDblClick ?? true
const stopPropagation = (event: Event) => {
if (!stopEvents()) return
event.stopPropagation()
}
const handleDblClick = (event: MouseEvent) => {
if (!allowDblClick()) return
stopPropagation(event)
openEditor(props.id, props.value())
}
return (
<Show
when={isEditing()}
fallback={
<span
class={props.displayClass ?? props.class}
onDblClick={handleDblClick}
onPointerDown={stopPropagation}
onMouseDown={stopPropagation}
onClick={stopPropagation}
onTouchStart={stopPropagation}
>
{props.value()}
</span>
}
>
<InlineInput
ref={(el) => {
if (frame !== undefined) cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
frame = undefined
if (!el.isConnected) return
el.focus()
})
}}
value={editorValue()}
class={props.class}
onInput={(event) => setEditor("value", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
editorKeyDown(event, props.onSave)
}}
onBlur={closeEditor}
onPointerDown={stopPropagation}
onClick={stopPropagation}
onDblClick={stopPropagation}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onTouchStart={stopPropagation}
/>
</Show>
)
}
return {
editor,
editorOpen,
editorValue,
openEditor,
closeEditor,
saveEditor,
editorKeyDown,
setEditor,
InlineEditor,
}
}
@@ -1,340 +0,0 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionLabel } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
export const ProjectIcon = (props: {
project: LocalProject
class?: string
notify?: boolean
working?: boolean
}): JSX.Element => {
const serverSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
return !permission.autoResponds(item, directory)
})
}),
)
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
classList={{ "badge-mask": notify() }}
/>
</div>
<Show when={notify()}>
<div
classList={{
"absolute top-px right-px size-1.5 rounded-full z-10": true,
"bg-surface-warning-strong": hasPermissions(),
"bg-icon-critical-base": !hasPermissions() && hasError(),
"bg-text-interactive-base": !hasPermissions() && !hasError(),
}}
/>
</Show>
<Show when={props.working}>
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
<Spinner class="size-[9px]" />
</div>
</Show>
</div>
)
}
export type SessionItemProps = {
session: SessionInfo
list: SessionInfo[]
navList?: Accessor<SessionInfo[]>
slug: string
mobile?: boolean
dense?: boolean
showTooltip?: boolean
showChild?: boolean
level?: number
sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void>
}
const SessionRow = (props: {
session: SessionInfo
slug: string
mobile?: boolean
dense?: boolean
tint: Accessor<string | undefined>
isWorking: Accessor<boolean>
hasPermissions: Accessor<boolean>
hasError: Accessor<boolean>
unseenCount: Accessor<number>
clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean>
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => sessionLabel(props.session)
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onFocus={props.warmFocus}
onClick={() => {
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
</A>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
const layout = useLayout()
const language = useLanguage()
const notification = useNotification()
const permission = usePermission()
const serverSync = useServerSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync().child(props.session.location.directory)
const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(
sessionStore.session,
serverSync().session.data.permission,
props.session.id,
(item) => {
return !permission.autoResponds(item, props.session.location.directory)
},
)
})
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return serverSync().session.data.session_working(props.session.id)
})
const tint = createMemo(() =>
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
)
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
const currentChild = createMemo(() => {
if (!props.showChild) return
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
})
const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.()
const list = nav?.some(
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
? nav
: props.list
props.prefetchSession(props.session, priority)
const idx = list.findIndex(
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
)
if (idx === -1) return
for (let step = 1; step <= span; step++) {
const next = list[idx + step]
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
const prev = list[idx - step]
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
}
}
const item = (
<SessionRow
session={props.session}
slug={props.slug}
mobile={props.mobile}
dense={props.dense}
tint={tint}
isWorking={isWorking}
hasPermissions={hasPermissions}
hasError={hasError}
unseenCount={unseenCount}
clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
/>
)
return (
<>
<div
data-session-id={props.session.id}
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
>
<div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Show
when={!tooltip()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={sessionLabel(props.session)}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
{item}
</Show>
</div>
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
<Show when={false}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</Show>
</div>
</div>
<Show when={currentChild()} keyed>
{(child) => (
<div class="w-full">
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
</div>
)}
</Show>
</>
)
}
export const NewSessionItem = (props: {
slug: string
mobile?: boolean
dense?: boolean
sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void
}): JSX.Element => {
const layout = useLayout()
const language = useLanguage()
const label = language.t("command.session.new")
const tooltip = () => props.mobile || !props.sidebarExpanded()
const item = (
<A
href={`/${props.slug}/session`}
end
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
>
<div class="shrink-0 size-6 flex items-center justify-center">
<IconV2 name="edit" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A>
)
return (
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show
when={!tooltip()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
{item}
</Tooltip>
}
>
{item}
</Show>
</div>
)
}
export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
return (
<div class="flex flex-col gap-1">
<For each={items}>
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
</For>
</div>
)
}
@@ -1,377 +0,0 @@
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Button } from "@opencode-ai/ui/button"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void
openSidebar: () => void
closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
}
export const ProjectDragOverlay = (props: {
projects: Accessor<LocalProject[]>
activeProject: Accessor<string | undefined>
}): JSX.Element => {
const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
return (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-xl p-1">
<ProjectIcon project={p()} />
</div>
)}
</Show>
)
}
const ProjectTile = (props: {
project: LocalProject
mobile?: boolean
sidebarHovering: Accessor<boolean>
selected: Accessor<boolean>
active: Accessor<boolean>
isWorking: Accessor<boolean>
overlay: Accessor<boolean>
suppressHover: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
setSuppressHover: (value: boolean) => void
language: ReturnType<typeof useLanguage>
}): JSX.Element => {
const notification = useNotification()
const layout = useLayout()
const unseenCount = createMemo(() =>
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clear = () =>
props
.dirs()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
return (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
props.setSuppressHover(value)
if (value) props.setOpen(false)
}}
>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true)
event.preventDefault()
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
if (props.suppressHover()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (props.suppressHover()) props.setSuppressHover(false)
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
if (props.suppressHover()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => {
props.setOpen(false)
if (props.selected()) {
layout.sidebar.toggle()
return
}
props.navigateToProject(props.project.worktree)
}}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify working={props.isWorking()} />
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(props.project.worktree)}
disabled={unseenCount() === 0}
onSelect={clear}
>
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
const ProjectPreviewPanel = (props: {
project: LocalProject
mobile?: boolean
selected: Accessor<boolean>
workspaceEnabled: Accessor<boolean>
workspaces: Accessor<string[]>
label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<div class="-m-3 p-2 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={props.workspaceEnabled()}
fallback={
<For each={props.projectSessions().slice(0, 2)}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
list={props.projectSessions()}
slug={base64Encode(props.project.worktree)}
dense
showTooltip
mobile={props.mobile}
/>
)}
</For>
}
>
<For each={props.workspaces()}>
{(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory))
return (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
</div>
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
</div>
<For each={sessions().slice(0, 2)}>
{(session) => (
<SessionItem
{...props.ctx.sessionProps}
session={session}
list={sessions()}
slug={base64Encode(directory)}
dense
showTooltip
mobile={props.mobile}
/>
)}
</For>
</div>
)
}}
</For>
</Show>
</div>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
props.ctx.openSidebar()
props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree)
}}
>
{props.language.t("sidebar.project.viewAllSessions")}
</Button>
</div>
</div>
)
export const SortableProject = (props: {
project: LocalProject
mobile?: boolean
ctx: ProjectSidebarContext
sortNow: Accessor<number>
}): JSX.Element => {
const serverSync = useServerSync()
const language = useLanguage()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({
menu: false,
suppressHover: false,
})
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = serverSync().child(directory, { bootstrap: false })
const kind =
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
return `${kind} : ${name}`
}
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
const isWorking = createMemo(() =>
dirs().some((directory) => {
return Object.keys(serverSync().session.data.session_status).some((id) => {
if (serverSync().session.get(id)?.location.directory !== directory) return false
return serverSync().session.data.session_working(id)
})
}),
)
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const workspaceSessions = (directory: string) => {
const [data] = serverSync().child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
const tile = () => (
<ProjectTile
project={props.project}
mobile={props.mobile}
sidebarHovering={props.ctx.sidebarHovering}
selected={selected}
active={active}
isWorking={isWorking}
overlay={overlay}
suppressHover={() => state.suppressHover}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)}
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)}
language={language}
/>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}>
<HoverCard
open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={tile()}
onOpenChange={(value) => {
if (state.menu) return
if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value)
}}
>
<ProjectPreviewPanel
project={props.project}
mobile={props.mobile}
selected={selected}
workspaceEnabled={workspaceEnabled}
workspaces={workspaces}
label={label}
projectSessions={projectSessions}
workspaceSessions={workspaceSessions}
ctx={props.ctx}
language={language}
/>
</HoverCard>
</Show>
</div>
)
}
@@ -1,125 +0,0 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout"
export const SidebarContent = (props: {
mobile?: boolean
opened: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
renderProject: (project: LocalProject) => JSX.Element
handleDragStart: (event: unknown) => void
handleDragEnd: () => void
handleDragOver: (event: DragEvent) => void
openProjectLabel: JSX.Element
openProjectKeybind: Accessor<string | undefined>
onOpenProject: () => void
renderProjectOverlay: () => JSX.Element
settingsLabel: Accessor<string>
settingsKeybind: Accessor<string | undefined>
onOpenSettings: () => void
helpLabel: Accessor<string>
onOpenHelp: () => void
renderPanel: () => JSX.Element
}): JSX.Element => {
const expanded = createMemo(() => !!props.mobile || props.opened())
const placement = () => (props.mobile ? "bottom" : "right")
let panel: HTMLDivElement | undefined
createEffect(() => {
const el = panel
if (!el) return
if (expanded()) {
el.removeAttribute("inert")
return
}
el.setAttribute("inert", "")
})
return (
<div class="flex h-full w-full min-w-0 overflow-hidden">
<div
data-component="sidebar-rail"
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
onMouseMove={props.aimMove}
>
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
onDragStart={props.handleDragStart}
onDragEnd={props.handleDragEnd}
onDragOver={props.handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider>
<Tooltip
placement={placement()}
value={
<div class="flex items-center gap-2">
<span>{props.openProjectLabel}</span>
<Show when={!props.mobile && !!props.openProjectKeybind()}>
<span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
</Show>
</div>
}
>
<IconButton
icon="plus"
variant="ghost"
size="large"
onClick={props.onOpenProject}
aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
/>
</Tooltip>
</div>
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"
variant="ghost"
size="large"
onClick={props.onOpenSettings}
aria-label={props.settingsLabel()}
/>
</TooltipKeybind>
<Tooltip placement={placement()} value={props.helpLabel()}>
<IconButton
icon="help"
variant="ghost"
size="large"
onClick={props.onOpenHelp}
aria-label={props.helpLabel()}
/>
</Tooltip>
</div>
</div>
<div
ref={(el) => {
panel = el
}}
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
aria-hidden={!expanded()}
>
{props.renderPanel()}
</div>
</div>
)
}
@@ -1,489 +0,0 @@
import { useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { createMediaQuery } from "@solid-primitives/media"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Button } from "@opencode-ai/ui/button"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions } from "./helpers"
import { useIsFetching } from "@tanstack/solid-query"
type InlineEditorComponent = (props: {
id: string
value: Accessor<string>
onSave: (next: string) => void
class?: string
displayClass?: string
editing?: boolean
stopPropagation?: boolean
openOnDblClick?: boolean
}) => JSX.Element
export type WorkspaceSidebarContext = {
currentDir: Accessor<string>
navList: Accessor<SessionInfo[]>
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
clearHoverProjectSoon: () => void
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
archiveSession: (session: SessionInfo) => Promise<void>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
openEditor: (id: string, value: string) => void
closeEditor: () => void
setEditor: (key: "value", value: string) => void
InlineEditor: InlineEditorComponent
isBusy: (directory: string) => boolean
workspaceExpanded: (directory: string, local: boolean) => boolean
setWorkspaceExpanded: (directory: string, value: boolean) => void
showResetWorkspaceDialog: (root: string, directory: string) => void
showDeleteWorkspaceDialog: (root: string, directory: string) => void
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
}
export const WorkspaceDragOverlay = (props: {
sidebarProject: Accessor<LocalProject | undefined>
activeWorkspace: Accessor<string | undefined>
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
}): JSX.Element => {
const serverSync = useServerSync()
const language = useLanguage()
const label = createMemo(() => {
const project = props.sidebarProject()
if (!project) return
const directory = props.activeWorkspace()
if (!directory) return
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
return `${kind} : ${name}`
})
return (
<Show when={label()}>
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
</Show>
)
}
const WorkspaceHeader = (props: {
local: Accessor<boolean>
busy: Accessor<boolean>
open: Accessor<boolean>
directory: string
language: ReturnType<typeof useLanguage>
branch: Accessor<string | undefined>
workspaceValue: Accessor<string>
workspaceEditActive: Accessor<boolean>
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
setEditor: WorkspaceSidebarContext["setEditor"]
projectId?: string
}): JSX.Element => (
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
<Spinner class="size-[15px]" />
</Show>
</div>
<span class="text-14-medium text-text-base shrink-0">
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
</span>
<Show
when={!props.local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{props.branch() ?? getFilename(props.directory)}
</span>
}
>
<props.InlineEditor
id={`workspace:${props.directory}`}
value={props.workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
props.setEditor("value", props.workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={props.workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
</div>
</div>
)
const WorkspaceActions = (props: {
directory: string
local: Accessor<boolean>
busy: Accessor<boolean>
menuOpen: Accessor<boolean>
pendingRename: Accessor<boolean>
setMenuOpen: (open: boolean) => void
setPendingRename: (value: boolean) => void
sidebarHovering: Accessor<boolean>
touch: Accessor<boolean>
language: ReturnType<typeof useLanguage>
workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void
}): JSX.Element => (
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
classList={{
"opacity-100 pointer-events-auto": props.menuOpen(),
"opacity-0 pointer-events-none": !props.menuOpen(),
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
}}
>
<DropdownMenu
modal={!props.sidebarHovering()}
open={props.menuOpen()}
onOpenChange={(open) => props.setMenuOpen(open)}
>
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md"
data-action="workspace-menu"
data-workspace={base64Encode(props.directory)}
aria-label={props.language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!props.pendingRename()) return
event.preventDefault()
props.setPendingRename(false)
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
}}
>
<DropdownMenu.Item
disabled={props.local()}
onSelect={() => {
props.setPendingRename(true)
props.setMenuOpen(false)
}}
>
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
disabled
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Show when={!props.touch()}>
<Tooltip value={props.language.t("command.session.new")} placement="top">
<IconButtonV2
icon={<IconV2 name="edit" size="small" />}
variant="ghost"
size="small"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session"
data-workspace={base64Encode(props.directory)}
aria-label={props.language.t("command.session.new")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.clearHoverProjectSoon()
props.navigateToNewSession()
}}
/>
</Tooltip>
</Show>
</div>
)
const WorkspaceSessionList = (props: {
slug: Accessor<string>
mobile?: boolean
ctx: WorkspaceSidebarContext
showNew: Accessor<boolean>
loading: Accessor<boolean>
sessions: Accessor<SessionInfo[]>
hasMore: Accessor<boolean>
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<nav class="flex flex-col gap-1">
<Show when={props.showNew()}>
<NewSessionItem
slug={props.slug()}
mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
/>
</Show>
<Show when={props.loading()}>
<SessionSkeleton />
</Show>
<For each={props.sessions()}>
{(session) => (
<SessionItem
session={session}
list={props.sessions()}
navList={props.ctx.navList}
slug={props.slug()}
mobile={props.mobile}
showChild
sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
/>
)}
</For>
<Show when={props.hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
size="large"
onClick={(e: MouseEvent) => {
void props.loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
{props.language.t("common.loadMore")}
</Button>
</div>
</Show>
</nav>
)
export const SortableWorkspace = (props: {
ctx: WorkspaceSidebarContext
directory: string
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
}): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
const serverSync = useServerSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
})
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const loading = () => fetching() > 0 && count() === 0
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync().project.loadSessions(props.directory)
}
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
const header = () => (
<WorkspaceHeader
local={local}
busy={busy}
open={open}
directory={props.directory}
language={language}
branch={() => workspaceStore.vcs?.branch}
workspaceValue={workspaceValue}
workspaceEditActive={workspaceEditActive}
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
projectId={props.project.id}
/>
)
const openWrapper = (value: boolean) => {
props.ctx.setWorkspaceExpanded(props.directory, value)
if (value) return
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
}
createEffect(() => {
if (!boot()) return
serverSync().child(props.directory, { bootstrap: true })
})
return (
<div
// @ts-ignore
use:sortable
classList={{
"opacity-30": sortable.isActiveDraggable,
"opacity-50 pointer-events-none": busy(),
}}
>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="py-1">
<div
class="group/workspace relative"
data-component="workspace-item"
data-workspace={base64Encode(props.directory)}
>
<div class="flex items-center gap-1">
<Show
when={workspaceEditActive()}
fallback={
<Collapsible.Trigger
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)}
>
{header()}
</Collapsible.Trigger>
}
>
<div
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
menu.open ? "pr-16" : "pr-2"
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
>
{header()}
</div>
</Show>
<WorkspaceActions
directory={props.directory}
local={local}
busy={busy}
menuOpen={() => menu.open}
pendingRename={() => menu.pendingRename}
setMenuOpen={(open) => setMenu("open", open)}
setPendingRename={(value) => setMenu("pendingRename", value)}
sidebarHovering={props.ctx.sidebarHovering}
touch={touch}
language={language}
workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)}
/>
</div>
</div>
</div>
<Collapsible.Content>
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
language={language}
/>
</Collapsible.Content>
</Collapsible>
</div>
)
}
export const LocalWorkspace = (props: {
ctx: WorkspaceSidebarContext
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
}): JSX.Element => {
const serverSync = useServerSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const workspace = createMemo(() => {
const [store, setStore] = serverSync().child(props.project.worktree)
return { store, setStore }
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const count = createMemo(() => sessions()?.length ?? 0)
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync().project.loadSessions(props.project.worktree)
}
return (
<div
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
language={language}
/>
</div>
)
}
@@ -2,7 +2,7 @@ import { For } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DockShell } from "@opencode-ai/ui/dock-surface" import { DockShell } from "@opencode-ai/ui/dock-surface"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SettingsProvider, useSettings } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
export default { export default {
title: "Composer/Revert Dock", title: "Composer/Revert Dock",
@@ -21,9 +21,6 @@ Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`). The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation. The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
### Layout split
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
### Notes ### Notes
- \`onRestore\` only mutates local story state, so nothing in the real session is affected. - \`onRestore\` only mutates local story state, so nothing in the real session is affected.
- Click the header to expand/collapse. Click "Restore message" to remove a row.`, - Click the header to expand/collapse. Click "Restore message" to remove a row.`,
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
}) as const }) as const
function Stage(props: { count: number }) { function Stage(props: { count: number }) {
const settings = useSettings()
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text })) const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
const [store, setStore] = createStore({ items: seed() }) const [store, setStore] = createStore({ items: seed() })
const v2 = () => settings.general.newLayoutDesigns()
const reset = () => setStore("items", seed()) const reset = () => setStore("items", seed())
const restore = (id: string) => const restore = (id: string) =>
setStore( setStore(
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
<button style={btn()} onClick={reset}> <button style={btn()} onClick={reset}>
Reset ({props.count}) Reset ({props.count})
</button> </button>
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
Layout: {v2() ? "v2" : "v1"}
</button>
</div> </div>
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */} {/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
<div style={{ display: "flex", "flex-direction": "column" }}> <div style={{ display: "flex", "flex-direction": "column" }}>
<SessionRevertDock items={store.items} onRestore={restore} /> <SessionRevertDock items={store.items} onRestore={restore} />
<DockShell <DockShell
data-dock-border-underlay={v2() ? "v2" : "legacy"} data-dock-border-underlay="v2"
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }} style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
classList={{ class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
"text-text-weak": !v2(),
}}
> >
Ask anything... Ask anything...
</DockShell> </DockShell>
+10 -12
View File
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js" import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server" import type { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context" import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model" import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) { export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl" return server.type === "sidecar" && server.variant === "wsl"
} }
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const openAddWsl = () => { const openAddWsl = () => {
dialog.push(() => <DialogAddWslServer />) void dialog.push(() => <DialogAddWslServer />)
} }
return ( return (
<Show <Show
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
} }
export function WslServerSettings(props: { export function WslServerSettings(props: {
controller: Controller domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers> servers: ReturnType<typeof useFilteredWslServers>
}) { }) {
const platform = usePlatform() const platform = usePlatform()
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
})) }))
const remove = (key: ServerConnection.Key) => { const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key)) request.mutate(() => props.domain.connection.remove(key))
} }
return ( return (
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
<div class="settings-v2-servers-copy"> <div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1"> <span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span> <span class="settings-v2-servers-name">{item.config.distro}</span>
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<Show when={opencodeAction()}> <Show when={opencodeAction()}>
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")} {language.t("wsl.server.retryStart")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
@@ -26,6 +26,7 @@ export default Runtime.handler(
const location = await client.location.get({ location: { directory: process.cwd() } }) const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({ const page = await client.session.list({
directory: location.directory, directory: location.directory,
workspace: location.workspaceID,
parentID: null, parentID: null,
order: "desc", order: "desc",
limit: 50, limit: 50,
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
) => ) =>
resolveSessionTarget({ resolveSessionTarget({
client, client,
location: { directory: next.location.directory }, location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent, agent: next.agent,
model: next.model model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } ? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
+2 -1
View File
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
? Promise.resolve(undefined) ? Promise.resolve(undefined)
: input.client.form.request : input.client.form.request
.list({ .list({
location: { directory: input.location.directory }, location: { directory: input.location.directory, workspace: input.location.workspaceID },
}) })
.catch(() => undefined), .catch(() => undefined),
]) ])
@@ -757,6 +757,7 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
{ {
headers: { headers: {
"x-opencode-directory": encodeURIComponent(location.directory), "x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
}, },
}, },
] ]
+1 -1
View File
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
next.model ?? next.model ??
(options.variant (options.variant
? await client.model ? await client.model
.default({ location: { directory: next.location.directory } }) .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data) .then((result) => result.data)
: undefined) : undefined)
const model = selected const model = selected
+1
View File
@@ -136,6 +136,7 @@ async function latestSession(
const page = await client.session.list( const page = await client.session.list(
{ {
directory: location.directory, directory: location.directory,
workspace: location.workspaceID,
parentID: null, parentID: null,
limit: SESSION_PAGE_LIMIT, limit: SESSION_PAGE_LIMIT,
order: "desc", order: "desc",
+2 -1
View File
@@ -426,13 +426,14 @@ describe("runNonInteractivePrompt", () => {
const globalOptions = { const globalOptions = {
headers: { headers: {
"x-opencode-directory": "%2Fwork%20tree", "x-opencode-directory": "%2Fwork%20tree",
"x-opencode-workspace": "wrk_1",
}, },
} }
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({ expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree" }, location: { directory: "/work tree", workspace: "wrk_1" },
}) })
expect(sdk.question.list).not.toHaveBeenCalled() expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled() expect(sdk.question.reject).not.toHaveBeenCalled()
+107 -60
View File
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
import type { Location } from "@opencode-ai/schema/location" import type { Location } from "@opencode-ai/schema/location"
import type { Agent } from "@opencode-ai/schema/agent" import type { Agent } from "@opencode-ai/schema/agent"
import type { Plugin } from "@opencode-ai/schema/plugin" import type { Plugin } from "@opencode-ai/schema/plugin"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { Session } from "@opencode-ai/schema/session" import type { Session } from "@opencode-ai/schema/session"
import type { AbsolutePath } from "@opencode-ai/schema/schema" import type { AbsolutePath } from "@opencode-ai/schema/schema"
import type { Project } from "@opencode-ai/schema/project" import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema" import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect" import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model" import type { Model } from "@opencode-ai/schema/model"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { SessionMessage } from "@opencode-ai/schema/session-message" import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt" import type { AgentAttachment } from "@opencode-ai/schema/prompt"
@@ -59,7 +59,9 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E> readonly get: ServerGetOperation<E>
} }
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint2_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint2_0Output = Location.Info export type Endpoint2_0Output = Location.Info
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E> export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
@@ -67,13 +69,15 @@ export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E> readonly get: LocationGetOperation<E>
} }
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint3_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> } export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E> export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
export type Endpoint3_1Input = { export type Endpoint3_1Input = {
readonly agentID: Agent.ID readonly agentID: Agent.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info } export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E> export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
@@ -83,7 +87,9 @@ export interface AgentApi<E = never> {
readonly get: AgentGetOperation<E> readonly get: AgentGetOperation<E>
} }
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint4_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> } export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E> export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
@@ -92,6 +98,7 @@ export interface PluginApi<E = never> {
} }
export type Endpoint5_0Input = { export type Endpoint5_0Input = {
readonly workspace?: Workspace.ID | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -926,11 +933,15 @@ export interface MessageApi<E = never> {
readonly list: MessageListOperation<E> readonly list: MessageListOperation<E>
} }
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint7_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> } export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E> export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint7_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined } export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E> export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
@@ -940,7 +951,7 @@ export interface ModelApi<E = never> {
} }
export type Endpoint8_0Input = { export type Endpoint8_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string readonly prompt: string
readonly model?: Model.Ref | undefined readonly model?: Model.Ref | undefined
} }
@@ -951,13 +962,15 @@ export interface GenerateApi<E = never> {
readonly text: GenerateTextOperation<E> readonly text: GenerateTextOperation<E>
} }
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint9_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> } export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E> export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
export type Endpoint9_1Input = { export type Endpoint9_1Input = {
readonly providerID: Provider.ID readonly providerID: Provider.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info } export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E> export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
@@ -967,19 +980,21 @@ export interface ProviderApi<E = never> {
readonly get: ProviderGetOperation<E> readonly get: ProviderGetOperation<E>
} }
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint10_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> } export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E> export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
export type Endpoint10_1Input = { export type Endpoint10_1Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined } export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E> export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export type Endpoint10_2Input = { export type Endpoint10_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly url: string readonly url: string
} }
export type Endpoint10_2Output = void export type Endpoint10_2Output = void
@@ -989,7 +1004,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
export type Endpoint10_3Input = { export type Endpoint10_3Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly key: string readonly key: string
readonly label?: string | undefined readonly label?: string | undefined
} }
@@ -1000,7 +1015,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
export type Endpoint10_4Input = { export type Endpoint10_4Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID readonly methodID: Integration.MethodID
readonly inputs: { readonly [x: string]: string } readonly inputs: { readonly [x: string]: string }
readonly label?: string | undefined readonly label?: string | undefined
@@ -1013,7 +1028,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
export type Endpoint10_5Input = { export type Endpoint10_5Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus } export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
export type IntegrationOauthStatusOperation<E = never> = ( export type IntegrationOauthStatusOperation<E = never> = (
@@ -1023,7 +1038,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
export type Endpoint10_6Input = { export type Endpoint10_6Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly code?: string | undefined readonly code?: string | undefined
} }
export type Endpoint10_6Output = void export type Endpoint10_6Output = void
@@ -1034,7 +1049,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
export type Endpoint10_7Input = { export type Endpoint10_7Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint10_7Output = void export type Endpoint10_7Output = void
export type IntegrationOauthCancelOperation<E = never> = ( export type IntegrationOauthCancelOperation<E = never> = (
@@ -1043,7 +1058,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
export type Endpoint10_8Input = { export type Endpoint10_8Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID readonly methodID: Integration.MethodID
readonly label?: string | undefined readonly label?: string | undefined
} }
@@ -1055,7 +1070,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
export type Endpoint10_9Input = { export type Endpoint10_9Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus } export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
export type IntegrationCommandStatusOperation<E = never> = ( export type IntegrationCommandStatusOperation<E = never> = (
@@ -1065,7 +1080,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
export type Endpoint10_10Input = { export type Endpoint10_10Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint10_10Output = void export type Endpoint10_10Output = void
export type IntegrationCommandCancelOperation<E = never> = ( export type IntegrationCommandCancelOperation<E = never> = (
@@ -1090,13 +1105,15 @@ export interface IntegrationApi<E = never> {
} }
} }
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint11_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> } export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E> export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
export type Endpoint11_1Input = { export type Endpoint11_1Input = {
readonly server: string readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
} }
export type Endpoint11_1Output = void export type Endpoint11_1Output = void
@@ -1104,26 +1121,28 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
export type Endpoint11_2Input = { export type Endpoint11_2Input = {
readonly server: string readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint11_2Output = void export type Endpoint11_2Output = void
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E> export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
export type Endpoint11_3Input = { export type Endpoint11_3Input = {
readonly server: string readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint11_3Output = void export type Endpoint11_3Output = void
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E> export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export type Endpoint11_4Input = { export type Endpoint11_4Input = {
readonly server: string readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint11_4Output = void export type Endpoint11_4Output = void
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E> export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint11_5Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog } export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E> export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
@@ -1138,7 +1157,7 @@ export interface McpApi<E = never> {
export type Endpoint12_0Input = { export type Endpoint12_0Input = {
readonly credentialID: Credential.ID readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly label: string readonly label: string
} }
export type Endpoint12_0Output = void export type Endpoint12_0Output = void
@@ -1146,7 +1165,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
export type Endpoint12_1Input = { export type Endpoint12_1Input = {
readonly credentialID: Credential.ID readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint12_1Output = void export type Endpoint12_1Output = void
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E> export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
@@ -1159,13 +1178,15 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = ReadonlyArray<Project.Info> export type Endpoint13_0Output = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E> export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint13_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_1Output = Project.Current export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E> export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = { export type Endpoint13_2Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint13_2Output = Project.Directories export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E> export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
@@ -1176,7 +1197,9 @@ export interface ProjectApi<E = never> {
readonly directories: ProjectDirectoriesOperation<E> readonly directories: ProjectDirectoriesOperation<E>
} }
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint14_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> } export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E> export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
@@ -1220,7 +1243,9 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E> readonly cancel: FormCancelOperation<E>
} }
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint15_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> } export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
export type PermissionRequestListOperation<E = never> = ( export type PermissionRequestListOperation<E = never> = (
input?: Endpoint15_0Input, input?: Endpoint15_0Input,
@@ -1278,14 +1303,14 @@ export interface PermissionApi<E = never> {
} }
export type Endpoint16_0Input = { export type Endpoint16_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: RelativePath | undefined readonly path?: RelativePath | undefined
} }
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> } export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E> export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
export type Endpoint16_1Input = { export type Endpoint16_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
@@ -1298,7 +1323,9 @@ export interface FileApi<E = never> {
readonly find: FileFindOperation<E> readonly find: FileFindOperation<E>
} }
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint17_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> } export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E> export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
@@ -1306,7 +1333,9 @@ export interface CommandApi<E = never> {
readonly list: CommandListOperation<E> readonly list: CommandListOperation<E>
} }
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint18_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> } export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E> export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
@@ -1321,12 +1350,14 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E> readonly subscribe: EventSubscribeOperation<E>
} }
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint20_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> } export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E> export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
export type Endpoint20_1Input = { export type Endpoint20_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command?: string | undefined readonly command?: string | undefined
readonly args?: ReadonlyArray<string> | undefined readonly args?: ReadonlyArray<string> | undefined
readonly cwd?: string | undefined readonly cwd?: string | undefined
@@ -1338,14 +1369,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
export type Endpoint20_2Input = { export type Endpoint20_2Input = {
readonly ptyID: Pty.ID readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info } export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E> export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_3Input = { export type Endpoint20_3Input = {
readonly ptyID: Pty.ID readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly size?: { readonly rows: number; readonly cols: number } | undefined readonly size?: { readonly rows: number; readonly cols: number } | undefined
} }
@@ -1354,7 +1385,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
export type Endpoint20_4Input = { export type Endpoint20_4Input = {
readonly ptyID: Pty.ID readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint20_4Output = void export type Endpoint20_4Output = void
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E> export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
@@ -1367,12 +1398,14 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E> readonly remove: PtyRemoveOperation<E>
} }
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> } export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E> export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = { export type Endpoint21_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string readonly command: string
readonly cwd?: string | undefined readonly cwd?: string | undefined
readonly timeout: number readonly timeout: number
@@ -1383,14 +1416,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
export type Endpoint21_2Input = { export type Endpoint21_2Input = {
readonly id: Shell.ID readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info } export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E> export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = { export type Endpoint21_3Input = {
readonly id: Shell.ID readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number readonly timeout: number
} }
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info } export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
@@ -1398,7 +1431,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
export type Endpoint21_4Input = { export type Endpoint21_4Input = {
readonly id: Shell.ID readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
} }
@@ -1415,7 +1448,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
export type Endpoint21_5Input = { export type Endpoint21_5Input = {
readonly id: Shell.ID readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint21_5Output = void export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E> export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
@@ -1429,7 +1462,9 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E> readonly remove: ShellRemoveOperation<E>
} }
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> } export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = ( export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input, input?: Endpoint22_0Input,
@@ -1458,7 +1493,9 @@ export interface QuestionApi<E = never> {
readonly reject: QuestionRejectOperation<E> readonly reject: QuestionRejectOperation<E>
} }
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> } export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E> export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
@@ -1468,7 +1505,7 @@ export interface ReferenceApi<E = never> {
export type Endpoint24_0Input = { export type Endpoint24_0Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly name?: string | undefined readonly name?: string | undefined
@@ -1478,7 +1515,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
export type Endpoint24_1Input = { export type Endpoint24_1Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly force: boolean readonly force: boolean
} }
@@ -1487,7 +1524,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
export type Endpoint24_2Input = { export type Endpoint24_2Input = {
readonly projectID: Project.ID readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
} }
export type Endpoint24_2Output = void export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E> export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
@@ -1498,16 +1535,20 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E> readonly refresh: ProjectCopyRefreshOperation<E>
} }
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info } export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E> export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> } export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E> export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = { export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode readonly mode: Vcs.Mode
readonly context?: number | undefined readonly context?: number | undefined
} }
@@ -1523,7 +1564,9 @@ export interface VcsApi<E = never> {
export type Endpoint26_0Output = ReadonlyArray<Location.Ref> export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E> export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Output = void export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E> export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
@@ -1548,12 +1591,14 @@ export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> } readonly v1: { readonly status: MigrationV1StatusOperation<E> }
} }
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> } export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E> export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint28_1Input = { export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly providerID?: WebSearch.ID | undefined readonly providerID?: WebSearch.ID | undefined
} }
@@ -1565,7 +1610,9 @@ export interface WebsearchApi<E = never> {
readonly query: WebsearchQueryOperation<E> readonly query: WebsearchQueryOperation<E>
} }
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry> export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E> export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
@@ -292,6 +292,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
preserveEffect<Endpoint5_0Output>()( preserveEffect<Endpoint5_0Output>()(
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.["workspace"],
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
@@ -448,6 +448,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session`, path: `/api/session`,
query: { query: {
workspace: input?.["workspace"],
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
@@ -476,7 +477,7 @@ export function make(options: ClientOptions) {
location: input?.["location"], location: input?.["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
+199 -68
View File
@@ -2283,7 +2283,9 @@ export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> } export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = { export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type LocationGetOutput = { export type LocationGetOutput = {
@@ -2293,7 +2295,9 @@ export type LocationGetOutput = {
} }
export type AgentListInput = { export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type AgentListOutput = { export type AgentListOutput = {
@@ -2303,7 +2307,9 @@ export type AgentListOutput = {
export type AgentGetInput = { export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"] readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type AgentGetOutput = { export type AgentGetOutput = {
@@ -2312,7 +2318,9 @@ export type AgentGetOutput = {
} }
export type PluginListInput = { export type PluginListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type PluginListOutput = { export type PluginListOutput = {
@@ -2321,7 +2329,19 @@ export type PluginListOutput = {
} }
export type SessionListInput = { export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly parentID?: string | null | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: { readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2332,6 +2352,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["limit"] }["limit"]
readonly order?: { readonly order?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2342,6 +2363,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["order"] }["order"]
readonly search?: { readonly search?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2352,6 +2374,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["search"] }["search"]
readonly parentID?: { readonly parentID?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2362,6 +2385,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["parentID"] }["parentID"]
readonly directory?: { readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2372,6 +2396,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["directory"] }["directory"]
readonly project?: { readonly project?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2382,6 +2407,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["project"] }["project"]
readonly subpath?: { readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2392,6 +2418,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["subpath"] }["subpath"]
readonly cursor?: { readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -3733,7 +3760,9 @@ export type MessageListInput = {
export type MessageListOutput = SessionMessagesResponse export type MessageListOutput = SessionMessagesResponse
export type ModelListInput = { export type ModelListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ModelListOutput = { export type ModelListOutput = {
@@ -3742,7 +3771,9 @@ export type ModelListOutput = {
} }
export type ModelDefaultInput = { export type ModelDefaultInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ModelDefaultOutput = { export type ModelDefaultOutput = {
@@ -3751,7 +3782,9 @@ export type ModelDefaultOutput = {
} }
export type GenerateTextInput = { export type GenerateTextInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: { readonly prompt: {
readonly prompt: string readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -3765,7 +3798,9 @@ export type GenerateTextInput = {
export type GenerateTextOutput = GenerateTextResponse["data"] export type GenerateTextOutput = GenerateTextResponse["data"]
export type ProviderListInput = { export type ProviderListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ProviderListOutput = { export type ProviderListOutput = {
@@ -3775,7 +3810,9 @@ export type ProviderListOutput = {
export type ProviderGetInput = { export type ProviderGetInput = {
readonly providerID: { readonly providerID: string }["providerID"] readonly providerID: { readonly providerID: string }["providerID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ProviderGetOutput = { export type ProviderGetOutput = {
@@ -3784,7 +3821,9 @@ export type ProviderGetOutput = {
} }
export type IntegrationListInput = { export type IntegrationListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationListOutput = { export type IntegrationListOutput = {
@@ -3794,7 +3833,9 @@ export type IntegrationListOutput = {
export type IntegrationGetInput = { export type IntegrationGetInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationGetOutput = { export type IntegrationGetOutput = {
@@ -3803,7 +3844,9 @@ export type IntegrationGetOutput = {
} }
export type IntegrationWellknownAddInput = { export type IntegrationWellknownAddInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly url: { readonly url: string }["url"] readonly url: { readonly url: string }["url"]
} }
@@ -3811,7 +3854,9 @@ export type IntegrationWellknownAddOutput = void
export type IntegrationConnectKeyInput = { export type IntegrationConnectKeyInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"] readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
} }
@@ -3820,7 +3865,9 @@ export type IntegrationConnectKeyOutput = void
export type IntegrationOauthConnectInput = { export type IntegrationOauthConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: { readonly methodID: {
readonly methodID: string readonly methodID: string
readonly inputs: { readonly [x: string]: string } readonly inputs: { readonly [x: string]: string }
@@ -3852,7 +3899,9 @@ export type IntegrationOauthConnectOutput = {
export type IntegrationOauthStatusInput = { export type IntegrationOauthStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationOauthStatusOutput = { export type IntegrationOauthStatusOutput = {
@@ -3863,7 +3912,9 @@ export type IntegrationOauthStatusOutput = {
export type IntegrationOauthCompleteInput = { export type IntegrationOauthCompleteInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly code?: { readonly code?: string | undefined }["code"] readonly code?: { readonly code?: string | undefined }["code"]
} }
@@ -3872,14 +3923,18 @@ export type IntegrationOauthCompleteOutput = void
export type IntegrationOauthCancelInput = { export type IntegrationOauthCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationOauthCancelOutput = void export type IntegrationOauthCancelOutput = void
export type IntegrationCommandConnectInput = { export type IntegrationCommandConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"] readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"] readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
} }
@@ -3892,7 +3947,9 @@ export type IntegrationCommandConnectOutput = {
export type IntegrationCommandStatusInput = { export type IntegrationCommandStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationCommandStatusOutput = { export type IntegrationCommandStatusOutput = {
@@ -3903,13 +3960,17 @@ export type IntegrationCommandStatusOutput = {
export type IntegrationCommandCancelInput = { export type IntegrationCommandCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type IntegrationCommandCancelOutput = void export type IntegrationCommandCancelOutput = void
export type McpListInput = { export type McpListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type McpListOutput = { export type McpListOutput = {
@@ -3919,7 +3980,9 @@ export type McpListOutput = {
export type McpAddInput = { export type McpAddInput = {
readonly server: { readonly server: string }["server"] readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly config: { readonly config: {
readonly config: readonly config:
| { | {
@@ -3955,27 +4018,35 @@ export type McpAddOutput = void
export type McpRemoveInput = { export type McpRemoveInput = {
readonly server: { readonly server: string }["server"] readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type McpRemoveOutput = void export type McpRemoveOutput = void
export type McpConnectInput = { export type McpConnectInput = {
readonly server: { readonly server: string }["server"] readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type McpConnectOutput = void export type McpConnectOutput = void
export type McpDisconnectInput = { export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"] readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type McpDisconnectOutput = void export type McpDisconnectOutput = void
export type McpResourceCatalogInput = { export type McpResourceCatalogInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type McpResourceCatalogOutput = { export type McpResourceCatalogOutput = {
@@ -3985,7 +4056,9 @@ export type McpResourceCatalogOutput = {
export type CredentialUpdateInput = { export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly label: { readonly label: string }["label"] readonly label: { readonly label: string }["label"]
} }
@@ -3993,7 +4066,9 @@ export type CredentialUpdateOutput = void
export type CredentialRemoveInput = { export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type CredentialRemoveOutput = void export type CredentialRemoveOutput = void
@@ -4001,20 +4076,26 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project> export type ProjectListOutput = Array<Project>
export type ProjectCurrentInput = { export type ProjectCurrentInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ProjectCurrentOutput = ProjectCurrent export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = { export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"] readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ProjectDirectoriesOutput = ProjectDirectories export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = { export type FormRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type FormRequestListOutput = { export type FormRequestListOutput = {
@@ -4868,7 +4949,9 @@ export type FormCancelInput = {
export type FormCancelOutput = void export type FormCancelOutput = void
export type PermissionRequestListInput = { export type PermissionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type PermissionRequestListOutput = { export type PermissionRequestListOutput = {
@@ -4974,7 +5057,9 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void export type PermissionReplyOutput = void
export type FileReadInput = { export type FileReadInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly path: string readonly path: string
} }
@@ -4982,11 +5067,11 @@ export type FileReadOutput = globalThis.Uint8Array
export type FileListInput = { export type FileListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined readonly path?: string | undefined
}["location"] }["location"]
readonly path?: { readonly path?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined readonly path?: string | undefined
}["path"] }["path"]
} }
@@ -4998,25 +5083,25 @@ export type FileListOutput = {
export type FileFindInput = { export type FileFindInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["location"] }["location"]
readonly query: { readonly query: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["query"] }["query"]
readonly type?: { readonly type?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["type"] }["type"]
readonly limit?: { readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
@@ -5029,7 +5114,9 @@ export type FileFindOutput = {
} }
export type CommandListInput = { export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type CommandListOutput = { export type CommandListOutput = {
@@ -5038,7 +5125,9 @@ export type CommandListOutput = {
} }
export type SkillListInput = { export type SkillListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type SkillListOutput = { export type SkillListOutput = {
@@ -5049,7 +5138,9 @@ export type SkillListOutput = {
export type EventSubscribeOutput = V2Event export type EventSubscribeOutput = V2Event
export type PtyListInput = { export type PtyListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type PtyListOutput = { export type PtyListOutput = {
@@ -5058,7 +5149,9 @@ export type PtyListOutput = {
} }
export type PtyCreateInput = { export type PtyCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command?: { readonly command?: {
readonly command?: string readonly command?: string
readonly args?: ReadonlyArray<string> readonly args?: ReadonlyArray<string>
@@ -5103,7 +5196,9 @@ export type PtyCreateOutput = {
export type PtyGetInput = { export type PtyGetInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type PtyGetOutput = { export type PtyGetOutput = {
@@ -5113,7 +5208,9 @@ export type PtyGetOutput = {
export type PtyUpdateInput = { export type PtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly title?: { readonly title?: {
readonly title?: string readonly title?: string
readonly size?: { readonly rows: number; readonly cols: number } readonly size?: { readonly rows: number; readonly cols: number }
@@ -5128,13 +5225,17 @@ export type PtyUpdateOutput = {
export type PtyRemoveInput = { export type PtyRemoveInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type PtyRemoveOutput = void export type PtyRemoveOutput = void
export type ShellListInput = { export type ShellListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ShellListOutput = { export type ShellListOutput = {
@@ -5143,7 +5244,9 @@ export type ShellListOutput = {
} }
export type ShellCreateInput = { export type ShellCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command: { readonly command: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
@@ -5177,7 +5280,9 @@ export type ShellCreateOutput = {
export type ShellGetInput = { export type ShellGetInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ShellGetOutput = { export type ShellGetOutput = {
@@ -5187,7 +5292,9 @@ export type ShellGetOutput = {
export type ShellTimeoutInput = { export type ShellTimeoutInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly timeout: { readonly timeout: number }["timeout"] readonly timeout: { readonly timeout: number }["timeout"]
} }
@@ -5199,17 +5306,17 @@ export type ShellTimeoutOutput = {
export type ShellOutputInput = { export type ShellOutputInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["location"] }["location"]
readonly cursor?: { readonly cursor?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["cursor"] }["cursor"]
readonly limit?: { readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["limit"] }["limit"]
@@ -5222,13 +5329,17 @@ export type ShellOutputOutput = {
export type ShellRemoveInput = { export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ShellRemoveOutput = void export type ShellRemoveOutput = void
export type QuestionRequestListInput = { export type QuestionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type QuestionRequestListOutput = { export type QuestionRequestListOutput = {
@@ -5256,7 +5367,9 @@ export type QuestionRejectInput = {
export type QuestionRejectOutput = void export type QuestionRejectOutput = void
export type ReferenceListInput = { export type ReferenceListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ReferenceListOutput = { export type ReferenceListOutput = {
@@ -5266,7 +5379,9 @@ export type ReferenceListOutput = {
export type ProjectCopyCreateInput = { export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"] readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"] readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"] readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
@@ -5276,7 +5391,9 @@ export type ProjectCopyCreateOutput = ProjectCopyCopy
export type ProjectCopyRemoveInput = { export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"] readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"] readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"] readonly force: { readonly directory: string; readonly force: boolean }["force"]
} }
@@ -5285,13 +5402,17 @@ export type ProjectCopyRemoveOutput = void
export type ProjectCopyRefreshInput = { export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"] readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ProjectCopyRefreshOutput = void export type ProjectCopyRefreshOutput = void
export type VcsGetInput = { export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type VcsGetOutput = { export type VcsGetOutput = {
@@ -5300,7 +5421,9 @@ export type VcsGetOutput = {
} }
export type VcsStatusInput = { export type VcsStatusInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type VcsStatusOutput = { export type VcsStatusOutput = {
@@ -5310,17 +5433,17 @@ export type VcsStatusOutput = {
export type VcsDiffInput = { export type VcsDiffInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" readonly mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["location"] }["location"]
readonly mode: { readonly mode: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" readonly mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["mode"] }["mode"]
readonly context?: { readonly context?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" readonly mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["context"] }["context"]
@@ -5334,7 +5457,9 @@ export type VcsDiffOutput = {
export type DebugLocationListOutput = Array<LocationRef> export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = { export type DebugLocationEvictInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type DebugLocationEvictOutput = void export type DebugLocationEvictOutput = void
@@ -5345,7 +5470,9 @@ export type MigrationV1StatusOutput =
| { status: "error"; error: string } | { status: "error"; error: string }
export type WebsearchProvidersInput = { export type WebsearchProvidersInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type WebsearchProvidersOutput = { export type WebsearchProvidersOutput = {
@@ -5354,7 +5481,9 @@ export type WebsearchProvidersOutput = {
} }
export type WebsearchQueryInput = { export type WebsearchQueryInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: { readonly query: string; readonly providerID?: string }["query"] readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"] readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
} }
@@ -5365,7 +5494,9 @@ export type WebsearchQueryOutput = {
} }
export type ConfigGetInput = { export type ConfigGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
} }
export type ConfigGetOutput = Array<ConfigEntry> export type ConfigGetOutput = Array<ConfigEntry>
+2 -1
View File
@@ -1021,12 +1021,13 @@ describe("OpenAPI.fromSpec", () => {
await Effect.runPromise( await Effect.runPromise(
location location
.execute({ location: { directory: "/tmp" } }) .execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)), .pipe(Effect.provide(client.layer)),
) )
const url = new URL(client.requests[0]!.url) const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp") expect(url.searchParams.get("location[directory]")).toBe("/tmp")
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
}) })
test("serializes supported simple and form parameter shapes", async () => { test("serializes supported simple and form parameter shapes", async () => {
+105 -70
View File
@@ -1,11 +1,15 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd", "id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"prevIds": [ "prevIds": [
"4d283c23-533f-4b87-9aa4-218f7fd36bca" "f14a9b18-8207-487e-a3d3-227e629ba9ad"
], ],
"ddl": [ "ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{ {
"name": "account_state", "name": "account_state",
"entityType": "tables" "entityType": "tables"
@@ -71,8 +75,84 @@
"entityType": "tables" "entityType": "tables"
}, },
{ {
"name": "workspace", "type": "text",
"entityType": "tables" "notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "''",
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "branch",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extra",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_used",
"entityType": "columns",
"table": "workspace"
}, },
{ {
"type": "integer", "type": "integer",
@@ -1305,63 +1385,18 @@
"table": "session_v2" "table": "session_v2"
}, },
{ {
"type": "text", "columns": [
"notNull": false, "project_id"
"autoincrement": false, ],
"default": null, "tableTo": "project",
"generated": null, "columnsTo": [
"name": "id", "id"
"entityType": "columns", ],
"table": "workspace" "onUpdate": "NO ACTION",
}, "onDelete": "CASCADE",
{ "nameExplicit": false,
"type": "text", "name": "fk_workspace_project_id_project_id_fk",
"notNull": true, "entityType": "fks",
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "binding",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "root",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "workspace" "table": "workspace"
}, },
{ {
@@ -1529,6 +1564,15 @@
"entityType": "pks", "entityType": "pks",
"table": "instruction_entry" "table": "instruction_entry"
}, },
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{ {
"columns": [ "columns": [
"id" "id"
@@ -1646,15 +1690,6 @@
"table": "session_v2", "table": "session_v2",
"entityType": "pks" "entityType": "pks"
}, },
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{ {
"columns": [ "columns": [
{ {
+18 -1
View File
@@ -41,7 +41,7 @@ export type Result =
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] } | { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
const unsupportedExperimental = [ const unsupportedExperimental = [
"disable_paste_summary", "disable_paste_summary",
"batch_tool", "batch_tool",
@@ -113,6 +113,23 @@ export function normalize(input: unknown): Result {
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) => const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)), canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
) )
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
: undefined
const migratedSmallModel = legacySmallModel
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
: undefined
if (legacySmallModel && !migratedSmallModel)
diagnostics.push({
kind: "unsupported",
path: ["small_model"],
message: "omitted unsupported legacy model reference",
})
if (migratedSmallModel)
legacyAgents.title = {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) => const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })), canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
) )
+6 -3
View File
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
} }
function expandHome(resource: string, home: string) { function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home if (resource === "~") return home
if (resource === "$HOME") return home if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5) const relative = resource.startsWith("~/")
if (resource.startsWith("$HOME\\")) return home + resource.slice(5) ? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource return resource
} }
@@ -0,0 +1,20 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/sql"
import { Project } from "../project"
import { Workspace } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<Project.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})
-2
View File
@@ -42,7 +42,5 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"), import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"), import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806150823_remove_workspace"),
import("./migration/20260806150824_add_workspace"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150823_remove_workspace",
up(tx) {
return Effect.gen(function* () {
// Legacy control-plane workspace references would otherwise route these
// sessions to hosted graphs whose workspace rows no longer exist.
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DROP TABLE \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,20 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150824_add_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
+13 -10
View File
@@ -4,6 +4,19 @@ import type { DatabaseMigration } from "./migration"
export default { export default {
up(tx) { up(tx) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`type\` text NOT NULL,
\`name\` text DEFAULT '' NOT NULL,
\`branch\` text,
\`directory\` text,
\`extra\` text,
\`project_id\` text NOT NULL,
\`time_used\` integer NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(` yield* tx.run(`
CREATE TABLE \`account_state\` ( CREATE TABLE \`account_state\` (
\`id\` integer PRIMARY KEY, \`id\` integer PRIMARY KEY,
@@ -203,16 +216,6 @@ export default {
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
); );
`) `)
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run( yield* tx.run(
+44 -204
View File
@@ -1,49 +1,23 @@
export * as FileMutation from "./file-mutation" export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Formatter } from "./formatter"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target { export interface Target {
readonly canonical: string
/** Lexical path for entry operations; remove unlinks the name, not the referent. */
readonly absolute: string readonly absolute: string
readonly resource: string readonly resource: string
} }
/** Seam-owned absence so tools never see backend error vocabularies. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FileMutation.NotFoundError", {
path: Schema.String,
}) {}
/** The target resolved to a directory where a file operation was required. */
export class NotAFileError extends Schema.TaggedErrorClass<NotAFileError>()("FileMutation.NotAFileError", {
path: Schema.String,
}) {}
/** A move wrote its destination but failed to remove the source. */
export class MoveIncompleteError extends Schema.TaggedErrorClass<MoveIncompleteError>()(
"FileMutation.MoveIncompleteError",
{
from: Schema.String,
to: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export interface WriteInput { export interface WriteInput {
readonly target: Target readonly target: Target
readonly content: string readonly content: string | Uint8Array
} }
export interface MoveInput { export interface TextWriteInput {
readonly from: Target readonly target: Target
readonly to: Target
/** Text for the destination; may differ from the source content. */
readonly content: string readonly content: string
} }
@@ -52,208 +26,74 @@ export interface WriteResult {
readonly target: string readonly target: string
readonly resource: string readonly resource: string
readonly existed: boolean readonly existed: boolean
/** Final text on disk after BOM handling and formatting. */
readonly content: string
} }
export interface Interface { export interface Interface {
/** Read a text file with the BOM stripped. BOM handling stays inside the seam. */
readonly read: (target: Target) => Effect.Effect<string, NotFoundError | NotAFileError | FSUtil.Error>
/**
* Write logical text while retaining an existing UTF-8 BOM and emitting at
* most one BOM. Runs configured formatters where the files live and reports
* the final text.
*/
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error> readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
* Write `content` to `to` with write semantics, then remove `from`. The readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
* destination inherits the source's BOM when it has none of its own.
*/
readonly move: (input: MoveInput) => Effect.Effect<WriteResult, MoveIncompleteError | FSUtil.Error>
readonly remove: (target: Target) => Effect.Effect<void, NotFoundError | FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
/** Normalize model-provided text to the BOM-free representation tools consume. */
export const normalizeText = (content: string) => Bom.split(content).text
/** /**
* Backend primitives the shared seam orchestrates. Errors arrive already in * Serialize file changes by absolute target. Conditional writes compare and
* the seam vocabulary so tool-level messages stay uniform across backends.
*/
interface Backend {
/** Full file bytes, or undefined when the path does not exist. */
readonly readOptional: (path: string) => Effect.Effect<Uint8Array | undefined, FSUtil.Error>
/** Classifies read failures; absence and errors report false. */
readonly isDirectory: (path: string) => Effect.Effect<boolean>
/** Creates parent directories, matching FSUtil.writeWithDirs. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, FSUtil.Error>
/** Unlink the lexical path; a symlink is removed, never its referent. */
readonly remove: (path: string) => Effect.Effect<void, NotFoundError | FSUtil.Error>
/** Format after write and report the final text, or undefined when no formatter applies. */
readonly format?: (path: string, bom: boolean) => Effect.Effect<string | undefined, FSUtil.Error>
}
const encoder = new TextEncoder()
/**
* Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do * write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content. * not overwrite changes made from the same stale content.
*/ */
const make = (backend: Backend): Interface => {
const locks = KeyedMutex.makeUnsafe<string>()
// Locks are acquired in sorted canonical order so multi-target operations
// cannot deadlock.
const withTargetLocks =
(...targets: readonly Target[]) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
[...new Set(targets.map((target) => target.canonical))]
.sort()
.reduceRight((locked, key) => locks.withLock(key)(locked), Effect.uninterruptible(effect))
// Happy-path reads are one operation; only the failure path checks whether
// the target is a directory.
const read = Effect.fn("FileMutation.read")((target: Target) =>
Effect.gen(function* () {
const content = yield* backend
.readOptional(target.canonical)
.pipe(
Effect.catch((error) =>
backend
.isDirectory(target.canonical)
.pipe(
Effect.flatMap((directory) =>
Effect.fail(directory ? new NotAFileError({ path: target.canonical }) : error),
),
),
),
)
if (content === undefined) return yield* new NotFoundError({ path: target.canonical })
return Bom.fromBytes(content).text
}),
)
const writeText = (target: Target, content: string, inheritedBom: boolean) =>
Effect.gen(function* () {
const next = Bom.split(content)
const current = yield* backend.readOptional(target.canonical)
const bom = Boolean(current && Bom.has(current)) || inheritedBom || next.bom
yield* backend.write(target.canonical, encoder.encode(Bom.join(next.text, bom)))
// Formatters may rewrite the file, so the backend re-syncs the BOM and
// reports the final text.
const formatted = backend.format ? yield* backend.format(target.canonical, bom) : undefined
return {
operation: "write" as const,
target: target.canonical,
resource: target.resource,
existed: current !== undefined,
content: formatted ?? next.text,
}
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLocks(input.target)(writeText(input.target, input.content, false)),
)
const move = Effect.fn("FileMutation.move")((input: MoveInput) =>
withTargetLocks(
input.from,
input.to,
)(
Effect.gen(function* () {
const source = yield* backend.readOptional(input.from.canonical)
const result = yield* writeText(input.to, input.content, Boolean(source && Bom.has(source)))
yield* backend
.remove(input.from.absolute)
.pipe(
Effect.mapError(
(cause) => new MoveIncompleteError({ from: input.from.canonical, to: input.to.canonical, cause }),
),
)
return result
}),
),
)
const remove = Effect.fn("FileMutation.remove")((target: Target) =>
withTargetLocks(target)(backend.remove(target.absolute)),
)
return Service.of({ read, write, move, remove })
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service const locks = KeyedMutex.makeUnsafe<string>()
return make({ const withTargetLock =
readOptional: (path) => (target: Target) =>
fs.readFile(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))), <A, E, R>(effect: Effect.Effect<A, E, R>) =>
isDirectory: (path) => locks.withLock(target.absolute)(Effect.uninterruptible(effect))
fs.stat(path).pipe(
Effect.map((info) => info.type === "Directory"), const writeResult = (target: Target, existed: boolean): WriteResult => ({
Effect.catch(() => Effect.succeed(false)), operation: "write",
), target: target.absolute,
write: (path, content) => fs.writeWithDirs(path, content), resource: target.resource,
remove: (path) => existed,
fs.remove(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => new NotFoundError({ path }))),
format: (path, bom) =>
formatter
.file(path)
.pipe(Effect.flatMap((formatted) => (formatted ? Bom.syncFile(fs, path, bom) : Effect.succeed(undefined)))),
}) })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Formatter.node] }) const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
// Same seam through WorkspaceEnvironment.Files. Absence stays typed; other
// environment failures surface as filesystem errors. No formatting: formatters
// are host binaries and cannot run against provider paths until an environment
// formatter runtime exists.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service const existed = yield* fs.exists(input.target.absolute)
return make({ yield* fs.writeWithDirs(input.target.absolute, input.content)
readOptional: (path) => return writeResult(input.target, existed)
env.files.read(path).pipe( }),
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.mapError(WorkspaceEnvironment.toFileSystemError("read")),
), ),
isDirectory: (path) => )
env.files.stat(path).pipe(
Effect.map((info) => info.type === "Directory"), const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
Effect.catch(() => Effect.succeed(false)), withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
), ),
write: (path, content) => )
env.files.write(path, content).pipe(Effect.mapError(WorkspaceEnvironment.toFileSystemError("write"))),
remove: (path) => return Service.of({ write, writeTextPreservingBom })
env.files
.remove(path)
.pipe(
Effect.mapError((error) =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? new NotFoundError({ path })
: WorkspaceEnvironment.toFileSystemError("remove")(error),
),
),
})
}), }),
) )
export const hostedNode = makeLocationNode({ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/** /**
* Deferred until the corresponding integrations exist. * Deferred until the corresponding integrations exist.
*/ */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists. // TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists. // TODO: Notify LSP and collect diagnostics after LSP runtime exists.
+43 -242
View File
@@ -7,9 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location" import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema" import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem" import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
import { WorkspaceEnvironment } from "./workspace/environment"
import { Ripgrep } from "./ripgrep"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({ export const ReadInput = Schema.Struct({
@@ -34,24 +32,7 @@ export type ListInput = typeof ListInput.Type
export { FindInput } export { FindInput }
export const DEFAULT_SEARCH_LIMIT = 100 export const DEFAULT_SEARCH_LIMIT = 100
/** One second of headroom over the hosted provider-side ripgrep kill. */ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export const DEFAULT_SEARCH_TIMEOUT_MS = (Ripgrep.HOSTED_KILL_TIMEOUT_SECONDS + 1) * 1_000
/** Applies the shared search budget, failing with a search-scoped message. */
export const searchTimeout =
<F>(fail: (message: string) => F) =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | F, R> =>
effect.pipe(
Effect.timeoutOrElse({
duration: DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
fail(
`Search timed out after ${DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
),
),
}),
)
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({ export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String, pattern: Schema.String,
@@ -66,251 +47,71 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
limit: Schema.optionalKey(PositiveInt), limit: Schema.optionalKey(PositiveInt),
}) {} }) {}
export interface SearchTarget {
readonly canonical: string
readonly absolute: string
}
export interface GlobSearchInput {
readonly target: SearchTarget
readonly pattern: string
readonly limit: number
}
export interface GrepSearchInput {
readonly target: SearchTarget
readonly pattern: string
readonly include?: string
readonly limit: number
}
export class SearchPathError extends Schema.TaggedErrorClass<SearchPathError>()("FileSystem.SearchPathError", {
path: Schema.String,
reason: Schema.Literals(["not_found", "not_directory"]),
}) {}
export class SearchError extends Schema.TaggedErrorClass<SearchError>()("FileSystem.SearchError", {
cause: Schema.Defect(),
}) {}
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()(
"FileSystem.InvalidPatternError",
{
pattern: Schema.String,
message: Schema.String,
},
) {}
const mapSearchError = (error: unknown) =>
error instanceof SearchPathError || error instanceof SearchError ? error : new SearchError({ cause: error })
const mapGrepError = (error: unknown) =>
error instanceof Ripgrep.InvalidPatternError
? new InvalidPatternError({ pattern: error.pattern, message: error.message })
: mapSearchError(error)
export const Event = FileSystem.Event export const Event = FileSystem.Event
/** Directories first, then lexicographic; shared by every entry listing. */
export const compareEntries = (a: Entry, b: Entry) =>
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1
export interface Interface { export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]> readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]> readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobSearchInput) => Effect.Effect<Entry[], SearchPathError | SearchError>
readonly grep: (input: GrepSearchInput) => Effect.Effect<Match[], SearchPathError | SearchError | InvalidPatternError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
const makeSearch = (
location: Location.Interface,
ripgrep: Ripgrep.Interface,
paths: Pick<typeof path.posix, "resolve" | "relative" | "dirname" | "basename">,
stat: (target: SearchTarget) => Effect.Effect<{ readonly type: string }, SearchPathError | SearchError>,
) => ({
glob: Effect.fn("FileSystem.glob")(function* (input: GlobSearchInput) {
if ((yield* stat(input.target)).type !== "Directory")
return yield* new SearchPathError({ path: input.target.absolute, reason: "not_directory" })
return yield* ripgrep
.glob({
cwd: input.target.canonical,
pattern: input.pattern,
limit: input.limit,
})
.pipe(
Effect.map((entries) =>
entries.map((entry) =>
Entry.make({
...entry,
path: RelativePath.make(
paths.relative(location.directory, paths.resolve(input.target.absolute, entry.path)),
),
}),
),
),
)
}, Effect.mapError(mapSearchError)),
grep: Effect.fn("FileSystem.grep")(function* (input: GrepSearchInput) {
const info = yield* stat(input.target)
const file = info.type === "File"
const cwd = file ? paths.dirname(input.target.absolute) : input.target.canonical
const root = file ? paths.dirname(input.target.absolute) : input.target.absolute
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: file ? paths.basename(input.target.absolute) : undefined,
include: input.include,
limit: input.limit,
})
.pipe(
Effect.map((matches) =>
matches.map((match) =>
Match.make({
...match,
entry: Entry.make({
...match.entry,
path: RelativePath.make(paths.relative(location.directory, paths.resolve(root, match.entry.path))),
}),
}),
),
),
)
}, Effect.mapError(mapGrepError)),
})
/**
* Entry primitives the shared read/list implementations consume. Escape
* failures and type mismatches are defects, matching route behavior.
*/
interface EntryBackend {
readonly paths: Pick<typeof path.posix, "resolve" | "relative" | "join" | "sep">
readonly contains: (parent: string, child: string) => boolean
readonly realPath: (target: string) => Effect.Effect<string>
/** Reads one file; non-file targets die. */
readonly readFile: (real: string) => Effect.Effect<Uint8Array>
/** Lists one directory; non-directory targets die. */
readonly listDir: (real: string) => Effect.Effect<readonly FSUtil.DirEntry[]>
}
const makeEntries = (location: Location.Interface, root: string, backend: EntryBackend) => {
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = backend.paths.resolve(location.directory, input ?? ".")
if (!backend.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* backend.realPath(absolute)
if (!backend.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real }
})
return {
read: Effect.fn("FileSystem.read")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
return {
content: yield* backend.readFile(target.real),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const items = yield* backend.listDir(target.real)
return items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = backend.paths.join(target.absolute, item.name)
const relative = backend.paths.relative(location.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? backend.paths.sep : "")),
type: item.type,
}),
]
})
.sort(compareEntries)
}),
}
}
const baseLayer = Layer.effect( const baseLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const search = yield* FileSystemSearch.Service const search = yield* FileSystemSearch.Service
const ripgrep = yield* Ripgrep.Service const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const searches = makeSearch(location, ripgrep, path, (target) => const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
fs.stat(target.canonical).pipe( const absolute = path.resolve(location.directory, input ?? ".")
Effect.catchReason("PlatformError", "NotFound", () => if (!FSUtil.contains(location.directory, absolute))
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })), return yield* Effect.die(new Error("Path escapes the location"))
), const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
Effect.mapError(mapSearchError), if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
return Service.of({
find: search.find,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return {
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
), ),
) )
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const entries = makeEntries(location, root, {
paths: path,
contains: FSUtil.contains,
realPath: (target) => fs.realPath(target).pipe(Effect.orDie),
readFile: (real) =>
Effect.gen(function* () {
const info = yield* fs.stat(real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return yield* fs.readFile(real).pipe(Effect.orDie)
}),
listDir: (real) =>
Effect.gen(function* () {
const info = yield* fs.stat(real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
}), }),
}) })
return Service.of({ find: search.find, ...searches, ...entries })
}), }),
) )
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer: baseLayer, layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node, Ripgrep.node], deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
// Mirrors baseLayer over WorkspaceEnvironment.Files with posix path rules.
// Host filesystem services never see provider paths. Type mismatches surface
// from the environment operation itself; no stat pre-checks.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const searches = makeSearch(location, ripgrep, path.posix, (target) =>
env.files.stat(target.canonical).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () =>
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
),
Effect.mapError(mapSearchError),
),
)
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const entries = makeEntries(location, root, {
paths: path.posix,
contains: FSUtil.containsPosix,
realPath: (target) => env.files.realPath(target).pipe(Effect.orDie),
readFile: (real) => env.files.read(real).pipe(Effect.orDie),
listDir: (real) => env.files.list(real).pipe(Effect.orDie),
})
return Service.of({
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
...searches,
...entries,
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node, Location.node, Ripgrep.node],
}) })
+1
View File
@@ -10,6 +10,7 @@ const prefixes = {
part: "prt", part: "prt",
pty: "pty", pty: "pty",
tool: "tool", tool: "tool",
workspace: "wrk",
} as const } as const
export function ascending(prefix: keyof typeof prefixes, given?: string) { export function ascending(prefix: keyof typeof prefixes, given?: string) {
+22 -149
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location" import { Location } from "./location"
import { Project } from "./project" import { Project } from "./project"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"]) export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type export type Kind = typeof Kind.Type
@@ -24,11 +23,6 @@ export const ResolveInput = Schema.Struct({
}) })
export type ResolveInput = typeof ResolveInput.Type export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
}) {}
export interface ExternalDirectoryAuthorization { export interface ExternalDirectoryAuthorization {
readonly action: "external_directory" readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */ /** Lexical directory used as the external approval boundary. */
@@ -45,15 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
}) })
export interface Target { export interface Target {
/** Canonical existing path, or missing path below a canonical directory. */ /** Absolute lexical path. */
readonly canonical: string
/**
* Lexical resolved path before symlink canonicalization. Reads and writes
* address the referent (canonical); entry operations like remove address
* the name itself.
*/
readonly absolute: string readonly absolute: string
/** Permission resource: Location-relative for internal paths, lexical absolute for external paths. */ /** Permission resource: Location-relative for internal paths, absolute for external paths. */
readonly resource: string readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization readonly externalDirectory?: ExternalDirectoryAuthorization
} }
@@ -64,73 +52,12 @@ export interface Interface {
* from the Location. Paths outside it require separate `external_directory` * from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation. * approval. This does not approve the mutation.
*/ */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error> readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
interface ResolvedPath { const slash = (value: string) => value.replaceAll("\\", "/")
readonly canonical: string
readonly type?:
| "File"
| "Directory"
| "SymbolicLink"
| "BlockDevice"
| "CharacterDevice"
| "FIFO"
| "Socket"
| "Unknown"
readonly directory: string
readonly lexicalDirectory: string
}
/**
* Resolution primitives the shared ancestor walk uses; absence reports
* undefined so the walk owns the missing-path vocabulary.
*/
interface WalkBackend<E> {
readonly paths: Pick<path.PlatformPath, "dirname" | "resolve" | "relative">
readonly realPathOptional: (path: string) => Effect.Effect<string | undefined, E>
readonly statOptional: (path: string) => Effect.Effect<{ readonly type: ResolvedPath["type"] } | undefined, E>
}
/**
* Canonicalize an existing path, or resolve a missing path below its nearest
* existing ancestor directory.
*/
const resolvePathWith = <E>(backend: WalkBackend<E>) =>
Effect.fnUntraced(function* (absolute: string) {
const existing = yield* backend.realPathOptional(absolute)
if (existing !== undefined) {
const info = yield* backend.statOptional(existing)
if (info === undefined) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : backend.paths.dirname(existing),
lexicalDirectory: info.type === "Directory" ? absolute : backend.paths.dirname(absolute),
} satisfies ResolvedPath
}
let anchor = backend.paths.dirname(absolute)
while (true) {
const canonical = yield* backend.realPathOptional(anchor)
if (canonical !== undefined) {
const info = yield* backend.statOptional(canonical)
if (info === undefined || info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: backend.paths.resolve(canonical, backend.paths.relative(anchor, absolute)),
directory: canonical,
lexicalDirectory: anchor,
} satisfies ResolvedPath
}
const parent = backend.paths.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@@ -138,43 +65,33 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
}
const resolvePath = resolvePathWith({
paths: path,
realPathOptional: (target) => notFound(fs.realPath(target)),
statOptional: (target) => notFound(fs.stat(target)),
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) { const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path) const absolute = path.resolve(location.directory, input.path)
// External access follows the requested path boundary. Symlinks reached through an if (FSUtil.contains(location.directory, absolute)) {
// internal path intentionally retain internal permission semantics after canonicalization.
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
const resolved = yield* resolvePath(absolute)
const external = !lexicallyInternal
const resource = external
? FSUtil.slash(absolute)
: FSUtil.slash(path.relative(location.directory, absolute) || ".")
const externalDirectory = resolved.lexicalDirectory
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
return { return {
canonical: resolved.canonical,
absolute, absolute,
resource, resource: slash(path.relative(location.directory, absolute) || "."),
externalDirectory: external } satisfies Target
? { }
const type =
input.kind === "directory"
? "Directory"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory", action: "external_directory",
directory: externalDirectory, directory: externalDirectory,
resource: externalResource, resource: externalResource,
save: FSUtil.slash( save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"), path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
), ),
} },
: undefined,
} satisfies Target } satisfies Target
}) })
@@ -187,47 +104,3 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie), layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node], deps: [FSUtil.node, Location.node],
}) })
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
// rules. Hosted paths are never external: everything outside the Location is
// rejected instead of routed to external_directory approval, because the
// approval boundary vocabulary is host-relative.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
// Canonicalized even when the Location sits on the workspace root: the
// environment does not guarantee a canonical directory (local fakes root
// at symlinked temp paths).
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolvePath = resolvePathWith({
paths: path.posix,
realPathOptional: (target) => WorkspaceEnvironment.optional(env.files.realPath(target)),
statOptional: (target) => WorkspaceEnvironment.optional(env.files.stat(target)),
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.posix.resolve(location.directory, input.path)
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
const resolved = yield* resolvePath(absolute)
if (!FSUtil.containsPosix(root, resolved.canonical))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
return {
canonical: resolved.canonical,
absolute,
resource: path.posix.relative(location.directory, absolute) || ".",
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer.pipe(Layer.orDie),
deps: [WorkspaceEnvironment.node, Location.node],
})
+3 -28
View File
@@ -11,7 +11,6 @@ import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter" import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Ripgrep } from "./ripgrep"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form" import { Form } from "./form"
import { Image } from "./image" import { Image } from "./image"
@@ -47,9 +46,8 @@ import { SessionGenerateNode } from "./session/generate-node"
import { McpTool } from "./tool/mcp" import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem" import { ReadToolFileSystem } from "./tool/read-filesystem"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolOutput } from "./tool-output"
import { Vcs } from "./vcs" import { Vcs } from "./vcs"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WorkspaceEnvironment } from "./workspace/environment"
export { LocationServiceMap } from "./location-service-map" export { LocationServiceMap } from "./location-service-map"
@@ -81,6 +79,7 @@ const locationServiceNodes = [
MCP.node, MCP.node,
Permission.node, Permission.node,
Tool.node, Tool.node,
ToolOutput.node,
Image.node, Image.node,
SkillInstructions.node, SkillInstructions.node,
ReferenceInstructions.node, ReferenceInstructions.node,
@@ -107,27 +106,6 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
export type LocationServices = LayerNode.Output<typeof locationServices> export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices> export type LocationError = LayerNode.Error<typeof locationServices>
/**
* Hosted graphs state their Location (no host discovery), read only global
* config sources, and bind the workspace environment; local graphs are
* byte-identical to before. Exported so tests can verify that every
* Location-path-consuming service is environment-backed in hosted graphs.
*/
export function hostedReplacements(ref: Location.Ref, workspaceID: Workspace.ID): LayerNode.Replacements {
return [
[Location.node, Location.hostedBoundNode(ref, workspaceID)],
[Config.node, Config.configured({ project: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false })],
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
[Ripgrep.node, Ripgrep.hostedNode],
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[ReadToolFileSystem.node, ReadToolFileSystem.hostedNode],
[Shell.node, Shell.hostedNode],
]
}
export function buildLocationServiceMap( export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [], replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> { ): Layer.Layer<LocationServiceMap.Service> {
@@ -142,10 +120,7 @@ export function buildLocationServiceMap(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const startedAt = performance.now() const startedAt = performance.now()
const workspaceID = ref.workspaceID const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const allReplacements = replacements.concat(
workspaceID ? hostedReplacements(ref, workspaceID) : [[Location.node, Location.boundNode(ref)]],
)
// Apply replacements during hoist, not afterward: replacements can // Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on // introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice // Project), and the hoist walk is the only pass that can still slice
-32
View File
@@ -1,9 +1,6 @@
import path from "path"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location" import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Project } from "./project" import { Project } from "./project"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node" import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
@@ -17,12 +14,6 @@ export interface Interface extends Info {
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
/**
* Path rules for a Location's directory. Hosted directories live in the
* provider filesystem: posix semantics regardless of the host platform.
*/
export const paths = (location: Pick<Info, "workspaceID">) => (location.workspaceID ? path.posix : path)
export const node = LayerNode.unbound(Service, tags.values.location) export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) => const layer = (ref: Ref) =>
@@ -46,26 +37,3 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref), layer: layer(ref),
deps: [Project.node], deps: [Project.node],
}) })
/**
* Hosted Locations state their Project instead of discovering it: host git
* and filesystem walks must never run against a provider directory. The
* Workspace root comes from the already-connected environment, avoiding a
* second workspace row read per graph build.
*/
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return Service.of({
directory: ref.directory,
workspaceID,
project: Project.hostedGlobal(env.directory),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+3
View File
@@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission" import { Permission } from "../permission"
import { Reference } from "../reference" import { Reference } from "../reference"
import { WebSearch } from "../websearch" import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions" import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell" import { Shell } from "../shell"
import { Skill } from "../skill" import { Skill } from "../skill"
@@ -88,6 +89,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const read = yield* ReadToolFileSystem.Service const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
@@ -119,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(ReadToolFileSystem.Service, read), Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference), Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch), Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions), Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell), Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill), Context.make(Skill.Service, skill),
+2 -3
View File
@@ -282,9 +282,7 @@ const layer = Layer.effect(
}) })
const updates = Stream.merge( const updates = Stream.merge(
config.changes().pipe( config.changes().pipe(
Stream.filterEffect((update) => Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)), Stream.merge(Stream.fromPubSub(configuredChanges)),
), ),
bus.subscribe([Event.Updated, SdkPlugins.Updated]), bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -340,6 +338,7 @@ export const node = makeLocationNode({
Form.node, Form.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
Reference.node, Reference.node,
Ripgrep.node,
SessionInstructions.node, SessionInstructions.node,
Shell.node, Shell.node,
Skill.node, Skill.node,
-12
View File
@@ -44,18 +44,6 @@ export interface Resolved {
readonly vcs?: Vcs readonly vcs?: Vcs
} }
/**
* The stated Project for hosted Locations: global identity until rediscovery
* inside the Workspace finds a repository. Canonical "/" matches the local
* non-VCS fallback on posix hosts, so persisting it never repoints the global
* row at a provider path.
*/
export const hostedGlobal = (root: string): Resolved => ({
id: ID.global,
directory: AbsolutePath.make(root),
canonical: AbsolutePath.make("/"),
})
// Keep this filesystem-only; permission checks use it and should not execute VCS commands. // Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* ( export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface, fs: FSUtil.Interface,
+5 -1
View File
@@ -1,5 +1,6 @@
export * as PtyTicket from "./ticket" export * as PtyTicket from "./ticket"
import { Workspace } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect" import { Cache, Context, Duration, Effect, Layer } from "effect"
@@ -13,6 +14,7 @@ export const ConnectToken = PtyTicket.ConnectToken
export type Scope = { export type Scope = {
readonly ptyID: PtyID readonly ptyID: PtyID
readonly directory?: string readonly directory?: string
readonly workspaceID?: Workspace.ID
} }
export interface Interface { export interface Interface {
@@ -23,7 +25,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {} export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) { function matches(record: Scope, input: Scope) {
return record.ptyID === input.ptyID && record.directory === input.directory return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
} }
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
+29 -100
View File
@@ -2,13 +2,11 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import type { Command } from "effect/unstable/process/ChildProcess"
import { Entry, Match } from "@opencode-ai/schema/filesystem" import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process" import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary" import { RipgrepBinary } from "./ripgrep/binary"
import { WorkspaceEnvironment } from "./workspace/environment"
/** /**
* Small core-owned ripgrep execution adapter. It deliberately exposes raw * Small core-owned ripgrep execution adapter. It deliberately exposes raw
@@ -20,13 +18,6 @@ import { WorkspaceEnvironment } from "./workspace/environment"
const ERROR_BYTES = 8 * 1024 const ERROR_BYTES = 8 * 1024
const MAX_SUBMATCHES = 100 const MAX_SUBMATCHES = 100
/**
* Provider-side kill for hosted searches, since hosted process kill is not
* implemented. Kept below FileSystem.DEFAULT_SEARCH_TIMEOUT_MS so the sandbox
* process dies before the caller's timeout fires.
*/
export const HOSTED_KILL_TIMEOUT_SECONDS = 29
const RawMatch = Schema.Struct({ const RawMatch = Schema.Struct({
type: Schema.Literal("match"), type: Schema.Literal("match"),
data: Schema.Struct({ data: Schema.Struct({
@@ -99,37 +90,26 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause
const isInvalidPattern = (stderr: string) => const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex") stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const normalizeRelativePath = (value: string) => const layer = Layer.effect(
value Service,
.replace(/^(?:\.[\\/])+/u, "") Effect.gen(function* () {
.replace(/^[\\/]+/u, "") const process = yield* AppProcess.Service
.replaceAll("\\", "/") const binary = yield* RipgrepBinary.Service
interface Backend {
readonly spawn: AppProcess.Interface["spawn"]
readonly awaitTruncated: boolean
readonly command: (input: {
readonly cwd: string
readonly args: string[]
readonly limit: number
readonly output: "lines" | "matches"
}) => Effect.Effect<Command, globalThis.Error>
}
const make = (backend: Backend) => {
const run = <A>(input: { const run = <A>(input: {
readonly cwd: string readonly cwd: string
readonly args: string[] readonly args: string[]
readonly limit: number readonly limit: number
readonly signal?: AbortSignal readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error> readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly output: "lines" | "matches"
readonly pattern?: string readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void> readonly onItem?: (item: A) => Effect.Effect<void>
}) => { }) => {
const program = Effect.scoped( const program = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* backend.spawn(yield* backend.command(input)) const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")), Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped, Effect.forkScoped,
@@ -149,11 +129,7 @@ const make = (backend: Backend) => {
Effect.map((chunk) => [...chunk]), Effect.map((chunk) => [...chunk]),
) )
const truncated = rows.length > input.limit const truncated = rows.length > input.limit
if (truncated) { if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
if (backend.awaitTruncated)
yield* Effect.all([handle.exitCode, Fiber.join(stderrFiber)], { concurrency: "unbounded" })
return { items: rows.slice(0, input.limit), truncated, partial: false }
}
const code = yield* handle.exitCode const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber) const stderr = yield* Fiber.join(stderrFiber)
@@ -182,7 +158,6 @@ const make = (backend: Backend) => {
cwd: input.cwd, cwd: input.cwd,
limit: input.limit, limit: input.limit,
signal: input.signal, signal: input.signal,
output: "lines",
args: [ args: [
"--no-config", "--no-config",
"--files", "--files",
@@ -192,7 +167,13 @@ const make = (backend: Backend) => {
"--glob=!**/.git/**", "--glob=!**/.git/**",
".", ".",
], ],
parse: (line) => Effect.succeed(normalizeRelativePath(line)), parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
}).pipe( }).pipe(
Effect.map((result) => Effect.map((result) =>
result.items.map((relative) => result.items.map((relative) =>
@@ -209,7 +190,6 @@ const make = (backend: Backend) => {
cwd: input.cwd, cwd: input.cwd,
limit: input.limit, limit: input.limit,
signal: input.signal, signal: input.signal,
output: "lines",
args: [ args: [
"--no-config", "--no-config",
"--files", "--files",
@@ -221,9 +201,13 @@ const make = (backend: Backend) => {
".", ".",
], ],
parse: (line) => { parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed( return Effect.succeed(
Entry.make({ Entry.make({
path: RelativePath.make(normalizeRelativePath(line)), path: RelativePath.make(relative),
type: "file", type: "file",
}), }),
) )
@@ -236,7 +220,6 @@ const make = (backend: Backend) => {
grep: (input) => grep: (input) =>
run<RawMatchData>({ run<RawMatchData>({
...input, ...input,
output: "matches",
args: [ args: [
"--no-config", "--no-config",
"--json", "--json",
@@ -257,6 +240,7 @@ const make = (backend: Backend) => {
return Schema.decodeUnknownEffect(RawMatch)(json).pipe( return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({ Effect.map((match) => ({
...match.data, ...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})), })),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
@@ -266,9 +250,13 @@ const make = (backend: Backend) => {
}).pipe( }).pipe(
Effect.map((result) => Effect.map((result) =>
result.items.map((match) => { result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({ return Match.make({
entry: Entry.make({ entry: Entry.make({
path: RelativePath.make(normalizeRelativePath(match.path.text)), path: RelativePath.make(relative),
type: "file", type: "file",
}), }),
line: match.line_number, line: match.line_number,
@@ -284,66 +272,7 @@ const make = (backend: Backend) => {
), ),
), ),
}) })
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service
return make({
spawn: (command) => process.spawn(command),
awaitTruncated: false,
command: (input) =>
binary.filepath.pipe(
Effect.map((executable) =>
ChildProcess.make(executable, input.args, {
cwd: input.cwd,
extendEnv: true,
stdin: "ignore",
}),
),
),
})
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [RipgrepBinary.node, AppProcess.node] }) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
export const hostedNode = makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return make({
spawn: (command) => env.process.spawn(command),
awaitTruncated: true,
command: (input) =>
Effect.succeed(
ChildProcess.make(
env.shell.executable,
[
...env.shell.args(
input.output === "lines"
? `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | head -n "$limit"`
: `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | awk -v limit="$limit" '{ print } /"type":"match"/ { if (++matches >= limit) exit }'`,
),
"opencode-ripgrep",
String(input.limit + 1),
...input.args,
],
{
cwd: input.cwd,
env: env.shell.environmentOverrides,
extendEnv: false,
detached: env.shell.detached,
stdin: "ignore",
},
),
),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+14 -28
View File
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session" import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm" import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project" import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model" import { Model } from "./model"
import { Location } from "./location" import { Location } from "./location"
import { SessionMessage } from "./session/message" import { SessionMessage } from "./session/message"
@@ -26,8 +27,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error" import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { Workspace } from "./workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map" import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
@@ -58,9 +58,12 @@ import { fileURLToPath } from "url"
// - by project // - by project
// - by subpath // - by subpath
// - by workspace (home is special)
export { ListAnchor } export { ListAnchor }
const ListInputBase = { const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional), search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
@@ -150,9 +153,7 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{ readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[] readonly data: SessionSchema.Info[]
}> }>
readonly create: ( readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
input: CreateInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
readonly fork: ( readonly fork: (
input: ForkInput, input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError> ) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -198,6 +199,7 @@ export interface Interface {
readonly move: (input: { readonly move: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
directory: AbsolutePath directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError> }) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
@@ -233,7 +235,7 @@ export interface Interface {
id?: Event.ID id?: Event.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
command: string command: string
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError> }) => Effect.Effect<void, NotFoundError>
readonly skill: (input: { readonly skill: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -278,7 +280,6 @@ const layer = Layer.effect(
const db = database.db const db = database.db
const bus = yield* Bus.Service const bus = yield* Bus.Service
const projects = yield* Project.Service const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service const global = yield* Global.Service
const execution = yield* SessionExecution.Service const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
@@ -290,17 +291,6 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>() const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
// Host Project discovery must not run against provider directories; hosted
// Sessions state the global Project until rediscovery inside the Workspace.
const hostedProject = Effect.fn("Session.hostedProject")(function* (
workspaceID: Workspace.ID,
directory: AbsolutePath,
) {
const workspace = yield* workspaces.get(workspaceID)
if (!FSUtil.containsPosix(workspace.root, directory))
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
return Project.hostedGlobal(workspace.root)
})
const persistProject = (project: Project.Resolved) => { const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type const vcs = project.vcs?.type
return db return db
@@ -338,9 +328,7 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location const location = parent?.location ?? input.location
if (location === undefined) if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID")) return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = location.workspaceID const project = yield* projects.resolve(location.directory)
? yield* hostedProject(location.workspaceID, location.directory)
: yield* projects.resolve(location.directory)
yield* persistProject(project) yield* persistProject(project)
const projected = yield* bus const projected = yield* bus
.publish( .publish(
@@ -352,9 +340,7 @@ const layer = Layer.effect(
projectID: project.id, projectID: project.id,
parentID: input.parentID, parentID: input.parentID,
location, location,
subpath: RelativePath.make( subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
FSUtil.slash(Location.paths(location).relative(project.directory, location.directory)),
),
title: input.title, title: input.title,
agent: input.agent, agent: input.agent,
model: input.model model: input.model
@@ -441,6 +427,7 @@ const layer = Layer.effect(
const sortColumn = SessionTable.time_updated const sortColumn = SessionTable.time_updated
const conditions: SQL[] = [] const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath)) if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
@@ -714,10 +701,10 @@ const layer = Layer.effect(
const expanded = const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded)) const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
if (current.location.directory === directory) return
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory }) if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory) const project = yield* projects.resolve(directory)
yield* persistProject(project) yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) { if ((yield* execution.active).has(input.sessionID)) {
@@ -728,9 +715,9 @@ const layer = Layer.effect(
SessionEvent.Moved, SessionEvent.Moved,
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
location: Location.Ref.make({ directory }), location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id, projectID: project.id,
subpath: RelativePath.make(FSUtil.slash(path.relative(project.directory, directory))), subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}, },
{ location: current.location }, { location: current.location },
) )
@@ -1026,7 +1013,6 @@ export const node = makeGlobalNode({
Database.node, Database.node,
Bus.node, Bus.node,
Project.node, Project.node,
Workspace.node,
SessionExecution.node, SessionExecution.node,
SessionStore.node, SessionStore.node,
LocationServiceMap.node, LocationServiceMap.node,
-14
View File
@@ -2,7 +2,6 @@ export * as SessionErrors from "./error"
import { Schema } from "effect" import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
@@ -19,19 +18,6 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
} }
} }
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
"Session.WorkspaceDirectoryError",
{
workspaceID: WorkspaceID,
directory: Schema.String,
root: Schema.String,
},
) {
override get message() {
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
}
}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", { export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
messageID: SessionMessage.ID, messageID: SessionMessage.ID,
+10 -3
View File
@@ -7,16 +7,16 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model" import { Model } from "../model"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater" import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending" import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
import { InstructionState } from "./instruction-state" import { InstructionState } from "./instruction-state"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql" import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import { Slug } from "../util/slug" import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import type { SessionSchema } from "./schema" import type { SessionSchema } from "./schema"
import { Workspace } from "../workspace" import { WorkspaceTable } from "../control-plane/workspace.sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }> type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
@@ -376,6 +376,13 @@ const layer = Layer.effectDiscard(
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
if (!event.data.location.workspaceID) return
yield* db
.update(WorkspaceTable)
.set({ time_used: Date.now() })
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
.run()
.pipe(Effect.orDie)
}), }),
) )
yield* bus.project(SessionEvent.Moved, (event) => yield* bus.project(SessionEvent.Moved, (event) =>
@@ -386,7 +393,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory, directory: event.data.location.directory,
path: event.data.subpath, path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}), ...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: null, workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created), time_updated: DateTime.toEpochMillis(event.created),
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
+4
View File
@@ -32,6 +32,7 @@ import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { SessionUsage } from "../usage" import { SessionUsage } from "../usage"
import { ToolOutput } from "../../tool-output"
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */ /** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{ type CallOutcome = Data.TaggedEnum<{
@@ -107,6 +108,7 @@ const layer = Layer.effect(
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation is a side effect of a successful step; it must not delay continuation. // Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably. // The in-flight set coalesces overlapping steps while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>() const titlesRunning = new Set<SessionSchema.ID>()
@@ -334,6 +336,7 @@ const layer = Layer.effect(
).pipe( ).pipe(
// The fiber owns its call: it publishes its own completion, masked so a // The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement. // finished execution always reaches its durable settlement.
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)), Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) => Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid), publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
@@ -562,6 +565,7 @@ export const node = makeLocationNode({
SessionCompaction.node, SessionCompaction.node,
SessionTitle.node, SessionTitle.node,
Snapshot.node, Snapshot.node,
ToolOutput.node,
Database.node, Database.node,
], ],
}) })
+17 -99
View File
@@ -6,14 +6,12 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer" import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell" import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config" import { Config } from "./config"
import { Bus } from "./bus" import { Bus } from "./bus"
import { Location } from "./location" import { Location } from "./location"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select" import { ShellSelect } from "./shell/select"
import { WorkspaceEnvironment } from "./workspace/environment"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell" import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks" import { PluginHooks } from "./plugin/hooks"
@@ -21,18 +19,6 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("She
id: Shell.ID, id: Shell.ID,
}) {} }) {}
export class InvalidCwdError extends Schema.TaggedErrorClass<InvalidCwdError>()("Shell.InvalidCwdError", {
path: Schema.String,
reason: Schema.Literals(["not_found", "not_directory", "unavailable"]),
cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
if (this.reason === "not_found") return `Working directory does not exist: ${this.path}`
if (this.reason === "not_directory") return `Working directory is not a directory: ${this.path}`
return `Unable to inspect working directory: ${this.path}`
}
}
// Exited processes stay observable (status, exit code, retained output) until removed explicitly. // Exited processes stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned commands do not accumulate unbounded state and output files. // Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25 const EXITED_LIMIT = 25
@@ -64,7 +50,7 @@ export interface Interface {
readonly create: <E = never, R = never>( readonly create: <E = never, R = never>(
input: Shell.CreateInput, input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>, before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | InvalidCwdError, R> ) => Effect.Effect<Shell.Info, E, R>
// Currently running commands only; exited shells are retained for get/output but excluded here. // Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]> readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError> readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -79,26 +65,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */ export const layer = (options?: ShellSelect.Options) => Layer.effect(
interface Backend {
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
/** Shell executable; re-evaluated per command so config changes apply. */
readonly shell: Effect.Effect<string>
readonly args: (shell: string, command: string) => readonly string[]
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
readonly env: Readonly<Record<string, string | undefined>>
readonly detached: boolean
readonly validateCwd: (cwd: string) => Effect.Effect<void, InvalidCwdError>
}
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const location = yield* Location.Service const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service const global = yield* Global.Service
const spawner = yield* backend const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const context = yield* Effect.context() const context = yield* Effect.context()
const runFork = Effect.runForkWith(context) const runFork = Effect.runForkWith(context)
@@ -167,7 +141,12 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
return session.info return session.info
}) })
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name)) const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id) const session = yield* require(id)
@@ -207,19 +186,18 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
command: input.command, command: input.command,
cwd: input.cwd ?? location.directory, cwd: input.cwd ?? location.directory,
timeout: input.timeout, timeout: input.timeout,
shell: yield* spawner.shell, shell: yield* resolve(),
env: { env: {
...spawner.env, ...process.env,
TERM: "xterm-256color", TERM: "xterm-256color",
OPENCODE_TERMINAL: "1", OPENCODE_TERMINAL: "1",
}, },
} }
yield* hooks.trigger("shell", "create.before", invocation) yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation) if (before) yield* before(invocation)
yield* spawner.validateCwd(invocation.cwd)
const id = Shell.ID.ascending() const id = Shell.ID.ascending()
const args = spawner.args(invocation.shell, invocation.command) const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`) const file = path.join(outputDir, `${id}.out`)
const info: Info = { const info: Info = {
@@ -240,12 +218,12 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
runFork( runFork(
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* spawner.spawn( const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, { ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd, cwd: invocation.cwd,
env: invocation.env, env: invocation.env,
stdin: "ignore", stdin: "ignore",
detached: spawner.detached, detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3), forceKillAfter: Duration.seconds(3),
}), }),
) )
@@ -359,74 +337,14 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
return Service.of({ name, create, list, get, wait, timeout, output, remove }) return Service.of({ name, create, list, get, wait, timeout, output, remove })
}), }),
) )
const validateCwdWith =
<E>(stat: (cwd: string) => Effect.Effect<{ readonly type: string }, E>, isNotFound: (error: E) => boolean) =>
(cwd: string) =>
stat(cwd).pipe(
Effect.mapError(
(error) =>
new InvalidCwdError({ path: cwd, reason: isNotFound(error) ? "not_found" : "unavailable", cause: error }),
),
Effect.flatMap((info) =>
info.type === "Directory"
? Effect.void
: Effect.fail(new InvalidCwdError({ path: cwd, reason: "not_directory" })),
),
)
export const layer = (options?: ShellSelect.Options) =>
layerWith(
Effect.gen(function* () {
const config = yield* Config.Service
const appProcess = yield* AppProcess.Service
const fs = yield* FSUtil.Service
return {
spawn: (command) => appProcess.spawn(command),
shell: config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
args: ShellSelect.args,
env: process.env,
detached: process.platform !== "win32",
validateCwd: validateCwdWith(
(cwd) => fs.stat(cwd),
(error) => error.reason._tag === "NotFound",
),
} satisfies Backend
}),
)
export function configured(options?: ShellSelect.Options) { export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, FSUtil.node, PluginHooks.node], deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
}) })
} }
export const node = configured() export const node = configured()
// Commands run inside the workspace: provider spawner, image shell lowering,
// and no host process.env leakage. Output capture files remain host-owned.
export const hostedNode = makeLocationNode({
service: Service,
layer: layerWith(
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return {
spawn: (command) => env.process.spawn(command),
shell: Effect.succeed(env.shell.executable),
args: (_shell, command) => env.shell.args(command),
env: env.shell.environmentOverrides,
detached: env.shell.detached,
validateCwd: validateCwdWith(
(cwd) => env.files.stat(cwd),
(error) => error._tag === "WorkspaceEnvironment.NotFoundError",
),
} satisfies Backend
}),
),
deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node],
})
+131
View File
@@ -0,0 +1,131 @@
export * as ToolOutput from "./tool-output"
import path from "path"
import type { Tool } from "@opencode-ai/schema/tool"
import { Context, Duration, Effect, Layer, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config"
import { Identifier } from "./id/id"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KiB
export const RETENTION = Duration.days(7)
export const DIRECTORY = "tool-output"
type Result = Tool.Result
export interface Interface {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) {
const cutoff = Identifier.timestamp(
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
)
const entries = yield* fs.readDirectory(directory).pipe(
Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(directory, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes)
return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = []
let bytes = 0
let hitBytes = false
for (const line of lines.slice(0, maxLines)) {
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
kept.push(line)
bytes += size
}
if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true
const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length
const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines"
const file = path.join(directory, Identifier.ascending("tool"))
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...`
const bounded: Tool.Content[] = []
let remaining = kept.join("\n").length
let seenText = false
let marked = false
for (const item of content) {
if (item.type === "file") {
bounded.push(item)
continue
}
if (seenText && remaining > 0) remaining--
seenText = true
if (remaining >= item.text.length) {
bounded.push(item)
remaining -= item.text.length
continue
}
if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) })
if (!marked) bounded.push({ type: "text", text: marker })
remaining = 0
marked = true
}
if (!marked) bounded.push({ type: "text", text: marker })
return {
...result,
content: bounded,
metadata: { ...result.metadata, truncated: true, outputPath: file },
}
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
}),
)
const cleanupLayer = Layer.effectDiscard(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe(
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.forkScoped,
)
}),
)
const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
})
+24 -9
View File
@@ -12,6 +12,8 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { fileDiff } from "./file-diff" import { fileDiff } from "./file-diff"
@@ -108,6 +110,8 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -148,14 +152,18 @@ export const Plugin = {
}) })
} }
const source = yield* files.read(target).pipe( const info = yield* fs
Effect.catchTag("FileMutation.NotFoundError", () => .stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
), ),
Effect.catchTag("FileMutation.NotAFileError", () =>
Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })),
),
) )
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n" const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending) const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -172,10 +180,9 @@ export const Plugin = {
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, (content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source, source,
) )
const content = FileMutation.normalizeText(replaced)
const preview = const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true) replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, content) ? fileDiff(target.resource, source, replaced)
: undefined : undefined
yield* permission.assert({ yield* permission.assert({
action: "edit", action: "edit",
@@ -196,9 +203,17 @@ export const Plugin = {
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`, message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
}) })
} }
const result = yield* files.write({ target, content: replaced }) const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute))
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return { return {
files: [fileDiff(result.resource, source, result.content)], files: [fileDiff(result.resource, source, formatted)],
replacements, replacements,
} satisfies Output } satisfies Output
}).pipe( }).pipe(
+31 -11
View File
@@ -5,8 +5,10 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema" import { RelativePath } from "../../schema"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -40,9 +42,9 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.glob", id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
@@ -80,22 +82,40 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* filesystem const entries = yield* ripgrep
.glob({ .glob({
target, cwd: target.absolute,
pattern: input.pattern, pattern: input.pattern,
limit: limit + 1, limit: limit + 1,
}) })
.pipe( .pipe(
FileSystem.searchTimeout((message) => new ToolFailure({ message })), Effect.timeoutOrElse({
Effect.catchTag("FileSystem.SearchPathError", (error) => duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail( Effect.fail(
new ToolFailure({ new ToolFailure({
message: message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
error.reason === "not_found" }),
? `Search path does not exist: ${searchPath ?? "."}` ),
: `Search path is not a directory: ${searchPath ?? "."}`, }),
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}), }),
), ),
), ),
@@ -105,7 +125,7 @@ export const Plugin = {
Effect.map((result) => ({ Effect.map((result) => ({
output: result.entries, output: result.entries,
content: toModelContent( content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: resolve(location.directory, entry.path) })), result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated, result.truncated,
), ),
metadata: { count: result.entries.length, truncated: result.truncated }, metadata: { count: result.entries.length, truncated: result.truncated },
+45 -13
View File
@@ -5,17 +5,19 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema" import { RelativePath } from "../../schema"
export const name = "grep" export const name = "grep"
export const Input = Schema.Struct({ export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern pattern: FileSystem.GrepInput.fields.pattern.check(
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" })) Schema.isMinLength(1, { message: "Pattern must not be empty" }),
.annotate({ ).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)", description: "Regular expression to search for in file contents (ripgrep syntax)",
}), }),
path: Schema.optionalKey(RelativePath).annotate({ path: Schema.optionalKey(RelativePath).annotate({
@@ -56,15 +58,16 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.grep", id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
({
name, name,
options: { codemode: false }, options: { codemode: false },
description: description:
@@ -96,18 +99,46 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const root = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs
.stat(root)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = info?.type === "Directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* filesystem const matches = yield* ripgrep
.grep({ .grep({
target, cwd,
pattern: input.pattern, pattern: input.pattern,
file: info?.type === "File" ? path.basename(root) : undefined,
include: input.include, include: input.include,
limit: limit + 1, limit: limit + 1,
}) })
.pipe( .pipe(
FileSystem.searchTimeout((message) => new ToolFailure({ message })), Effect.timeoutOrElse({
Effect.catchTag("FileSystem.SearchPathError", () => duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
), ),
) )
return { matches: matches.slice(0, limit), truncated: matches.length > limit } return { matches: matches.slice(0, limit), truncated: matches.length > limit }
@@ -117,7 +148,7 @@ export const Plugin = {
content: toModelContent( content: toModelContent(
result.matches.map((match) => ({ result.matches.map((match) => ({
...match, ...match,
entry: { ...match.entry, path: resolve(location.directory, match.entry.path) }, entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})), })),
result.truncated, result.truncated,
), ),
@@ -126,12 +157,13 @@ export const Plugin = {
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ToolFailure error instanceof ToolFailure
? error ? error
: error instanceof FileSystem.InvalidPatternError : error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` }) ? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
), ),
), ),
}), }),
),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+127 -65
View File
@@ -6,8 +6,11 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError" import { PlatformError } from "effect/PlatformError"
import { FileMutation } from "../../file-mutation" import path from "path"
import { LocationMutation } from "../../location-mutation" import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch" import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt" import DESCRIPTION from "../patch.txt"
@@ -54,13 +57,21 @@ type Prepared =
readonly moveTarget?: Target readonly moveTarget?: Target
}) })
type Target = LocationMutation.Target interface Target {
readonly absolute: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = { export const Plugin = {
id: "opencode.tool.patch", id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const fs = yield* FSUtil.Service
const files = yield* FileMutation.Service const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -95,40 +106,37 @@ export const Plugin = {
const prepared: Prepared[] = [] const prepared: Prepared[] = []
const targets: Target[] = [] const targets: Target[] = []
const updates = new Map<string, string>() const updates = new Map<string, string>()
const resolveHunkTarget = (value: string) =>
mutation
.resolve({ path: value, kind: "file" })
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
),
)
for (const hunk of hunks) { for (const hunk of hunks) {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const target = yield* resolveHunkTarget(hunk.path) const target = resolveTarget(location, hunk.path)
targets.push(target) targets.push(target)
if (target.externalDirectory) { if (target.externalDirectory) {
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory), action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
} }
if (hunk.type === "add") { if (hunk.type === "add") {
const contents =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({ prepared.push({
...hunk, ...hunk,
contents,
target, target,
before: "", before: "",
after: FileMutation.normalizeText(contents), after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
}) })
return return
} }
if (hunk.type === "delete") { if (hunk.type === "delete") {
const content = yield* files.read(target).pipe( const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
@@ -136,30 +144,52 @@ export const Plugin = {
}), }),
), ),
) )
prepared.push({ ...hunk, target, before: content, after: "" }) prepared.push({ ...hunk, target, before: content.text, after: "" })
return return
} }
const previous = updates.get(target.canonical) const previous = updates.get(target.absolute)
const original = const original =
previous ?? previous ??
(yield* files.read(target).pipe( (yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}), }),
), ),
)) )
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = Bom.split(original).text
const update = yield* Effect.try({ const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original), try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }), catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
}) })
const content = FileMutation.normalizeText(update.content) const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget) if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) { if (moveTarget?.externalDirectory) {
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory), action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.absolute,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
@@ -169,11 +199,11 @@ export const Plugin = {
...hunk, ...hunk,
target, target,
content: Patch.joinBom(update.content, update.bom), content: Patch.joinBom(update.content, update.bom),
before: original, before,
after: content, after: update.content,
moveTarget, moveTarget,
}) })
if (!moveTarget) updates.set(target.canonical, content) if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
}).pipe( }).pipe(
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ToolFailure error instanceof ToolFailure
@@ -198,76 +228,92 @@ export const Plugin = {
source, source,
}) })
// FileMutation.write formats where the files live and reports
// the final text, so the diff output reflects disk.
const formatted = new Map<string, string>()
yield* Effect.forEach( yield* Effect.forEach(
prepared, prepared,
(change) => (change) =>
Effect.gen(function* () { Effect.gen(function* () {
if (change.type === "add") { if (change.type === "add") {
const result = yield* files yield* fs
.write({ target: change.target, content: change.contents }) .writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({ applied.push({
type: change.type, type: change.type,
resource: change.target.resource, resource: change.target.resource,
target: change.target.canonical, target: change.target.absolute,
}) })
return return
} }
if (change.type === "delete") { if (change.type === "delete") {
yield* files yield* fs
.remove(change.target) .remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({ applied.push({
type: change.type, type: change.type,
resource: change.target.resource, resource: change.target.resource,
target: change.target.canonical, target: change.target.absolute,
}) })
return return
} }
if (change.moveTarget) { if (change.moveTarget) {
const moveTarget = change.moveTarget const moveTarget = change.moveTarget
const result = yield* files yield* fs
.move({ from: change.target, to: moveTarget, content: change.content }) .writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs
.remove(change.target.absolute)
.pipe( .pipe(
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof FileMutation.MoveIncompleteError fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
? fail(
`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`,
error.cause,
)
: fail(`Failed to write ${moveTarget.resource}`, error),
), ),
) )
formatted.set(moveTarget.canonical, result.content)
applied.push({ applied.push({
type: change.type, type: change.type,
resource: change.moveTarget.resource, resource: change.moveTarget.resource,
target: change.moveTarget.canonical, target: change.moveTarget.absolute,
}) })
return return
} }
const result = yield* files yield* fs
.write({ target: change.target, content: change.content }) .writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({ applied.push({
type: change.type, type: change.type,
resource: change.target.resource, resource: change.target.resource,
target: change.target.canonical, target: change.target.absolute,
}) })
}), }),
{ discard: true }, { discard: true },
) )
const fileDiffs = yield* Effect.forEach(prepared, (change) => { const formatted = new Map<string, string>()
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change)) if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
return Effect.succeed(patchFile(change, formatted.get(target.canonical))) return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
}) })
return { applied, files: fileDiffs } return { applied, files }
}).pipe( }).pipe(
Effect.map((output) => ({ Effect.map((output) => ({
output, output,
@@ -297,13 +343,8 @@ export const Plugin = {
) )
}), }),
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (error instanceof FileMutation.NotFoundError) return "file does not exist"
if (error instanceof FileMutation.NotAFileError) return "path is a directory"
if (error instanceof LocationMutation.PathError)
return error.reason === "outside_workspace"
? `path is outside the workspace: ${error.path}`
: `ancestor is not a directory: ${error.path}`
if (error instanceof PlatformError) { if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist" if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message return error.reason.description ?? error.reason.message
@@ -313,7 +354,7 @@ function errorMessage(error: unknown) {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type { function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after)) const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
const counts = const counts =
change.type === "delete" change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length } ? { additions: 0, deletions: change.before.split("\n").length }
@@ -360,3 +401,24 @@ function trimDiff(diff: string) {
}) })
.join("\n") .join("\n")
} }
function resolveTarget(location: Location.Interface, value: string): Target {
const absolute =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, absolute) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
const directory = path.dirname(absolute)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
absolute,
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+8 -10
View File
@@ -53,7 +53,7 @@ export const Plugin = {
messageID: context.messageID, messageID: context.messageID,
id: context.id, id: context.id,
} }
const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) const target = yield* mutation.resolve({ path: input.path })
const external = target.externalDirectory const external = target.externalDirectory
if (external) if (external)
yield* permission.assert({ yield* permission.assert({
@@ -63,7 +63,7 @@ export const Plugin = {
source, source,
}) })
const resource = target.resource const resource = target.resource
const absolute = AbsolutePath.make(target.canonical) const absolute = AbsolutePath.make(target.absolute)
yield* permission.assert({ yield* permission.assert({
action: name, action: name,
resources: [resource], resources: [resource],
@@ -74,7 +74,7 @@ export const Plugin = {
}) })
const type = yield* reader const type = yield* reader
.inspect(absolute) .inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical))) .pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content = const content =
type === "directory" type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit }) ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
@@ -88,10 +88,8 @@ export const Plugin = {
// is discovered); for a file it starts at the file's dirname. External reads are // is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read. // skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
// External reads skip discovery, and so do hosted Locations: if (target.externalDirectory !== undefined) return
// the walk reads the host filesystem. const resolved = yield* fs.resolve(target.absolute)
if (external !== undefined || location.workspaceID !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory) const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already // up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter. // supplied by core initial instructions) is dropped by the dirname filter.
@@ -116,13 +114,13 @@ export const Plugin = {
Effect.map((output) => ({ Effect.map((output) => ({
output, output,
content: toModelContent(input.path, input.offset, output), content: toModelContent(input.path, input.offset, output),
metadata: { truncated: output.type === "file" ? false : output.truncated },
})), })),
Effect.mapError((error) => { Effect.mapError((error) => {
if (error instanceof ToolFailure) return error if (error instanceof ToolFailure) return error
const message = const message =
error instanceof ReadToolFileSystem.BinaryFileError || error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError || error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError || error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError error instanceof ReadToolFileSystem.PathKindError
? error.message ? error.message
@@ -135,9 +133,9 @@ export const Plugin = {
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) { const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const base = basename(input).toLowerCase() const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe( const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
Effect.map((entries) => Effect.map((entries) =>
entries entries
.filter((entry) => { .filter((entry) => {
+36 -19
View File
@@ -4,7 +4,9 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool" import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Array, Deferred, Effect, Schema, Scope } from "effect" import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "../../config"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime" import { PluginRuntime } from "../../plugin/runtime"
@@ -12,10 +14,10 @@ import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema" import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell" import { Shell } from "../../shell"
import { ShellParse } from "../../shell/parse" import { ShellParse } from "../../shell/parse"
import { ToolOutput } from "../../tool-output"
export const name = "shell" export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED = "The command was moved to the background." const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION = const BACKGROUND_INSTRUCTION =
@@ -81,9 +83,11 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const config = yield* Config.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
@@ -120,8 +124,7 @@ export const Plugin = {
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add( draft.add({
({
name, name,
options: { codemode: false }, options: { codemode: false },
description: description(), description: description(),
@@ -146,17 +149,17 @@ export const Plugin = {
(invocation) => (invocation) =>
Effect.gen(function* () { Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" }) const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical) const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
const directories = yield* Effect.forEach(parsed.directories, (directory) => const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }), mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
) )
invocation.cwd = target.canonical invocation.cwd = target.absolute
finalTimeout = invocation.timeout finalTimeout = invocation.timeout
const external = Array.dedupeWith( const external = [target, ...directories]
[target, ...directories]
.map((item) => item.externalDirectory) .map((item) => item.externalDirectory)
.filter((item) => item !== undefined), .filter((item) => item !== undefined)
(left, right) => left.resource === right.resource, .filter(
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
) )
if (external.length > 0) if (external.length > 0)
yield* permission.assert({ yield* permission.assert({
@@ -176,20 +179,35 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}), }),
) )
yield* context.progress({ shellID: info.id }) yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () { const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER }) const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, { const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES), cursor: Math.max(0, latest.size - maxBytes),
limit: MAX_CAPTURE_BYTES, limit: maxBytes,
}) })
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : "" const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return { return {
output: `${page.output || "(no output)"}${notice}`, output: `${output || "(no output)"}${notice}`,
truncated, truncated,
} }
}) })
@@ -243,9 +261,9 @@ export const Plugin = {
} }
} }
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( const result = yield* runtime.job
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), .block({ id: job.id, sessionID: context.sessionID })
) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") { if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0) yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command) yield* notifyWhenDone(context.sessionID, context.id, info.command)
@@ -282,7 +300,6 @@ export const Plugin = {
), ),
), ),
}), }),
),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
+13 -16
View File
@@ -9,7 +9,10 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { fileDiff } from "./file-diff" import { fileDiff } from "./file-diff"
@@ -45,6 +48,8 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -72,16 +77,11 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const current = yield* files const current = yield* Bom.readFile(fs, target.absolute).pipe(
.read(target) Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
.pipe(Effect.catchTag("FileMutation.NotFoundError", () => Effect.succeed(undefined)))
const content = FileMutation.normalizeText(input.content)
const preview = fileDiff(
target.resource,
current ?? "",
content,
current === undefined ? "added" : "modified",
) )
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
yield* permission.assert({ yield* permission.assert({
action: "edit", action: "edit",
resources: [target.resource], resources: [target.resource],
@@ -91,13 +91,10 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const result = yield* files.write({ target, content: input.content }) const result = yield* files.writeTextPreservingBom({ target, content: input.content })
return { const bom = (yield* Bom.readFile(fs, target.absolute)).bom
operation: result.operation, if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
target: result.target, return result
resource: result.resource,
existed: result.existed,
}
}).pipe( }).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+47 -235
View File
@@ -2,13 +2,11 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema, Scope } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema" import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
import { WorkspaceEnvironment } from "../workspace/environment"
export const MAX_READ_LINES = 2_000 export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024 export const MAX_READ_BYTES = 50 * 1024
@@ -36,14 +34,6 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
} }
} }
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
resource: Schema.String,
}) {
override get message() {
return `File is not valid UTF-8: ${this.resource}`
}
}
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()( export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError", "ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number }, { offset: Schema.Number },
@@ -63,13 +53,7 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
} }
export type InspectError = FSUtil.Error | PathKindError export type InspectError = FSUtil.Error | PathKindError
export type ReadError = export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
| FSUtil.Error
| BinaryFileError
| MediaIngestLimitError
| MalformedUtf8Error
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({ export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt), offset: Schema.optionalKey(NonNegativeInt),
@@ -92,9 +76,15 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
next: Schema.optionalKey(PositiveInt), next: Schema.optionalKey(PositiveInt),
}) {} }) {}
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
export const ListEntry = Schema.Struct({
path: RelativePath,
type: Schema.Literals(["file", "directory", "symlink"]),
}).annotate({ identifier: "ReadTool.ListEntry" })
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
type: Schema.Literal("list-page"), type: Schema.Literal("list-page"),
entries: Schema.Array(FileSystem.Entry), entries: Schema.Array(ListEntry),
truncated: Schema.Boolean, truncated: Schema.Boolean,
next: Schema.optionalKey(PositiveInt), next: Schema.optionalKey(PositiveInt),
}) {} }) {}
@@ -111,54 +101,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
type FileType = WorkspaceEnvironment.FileInfo["type"]
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
export interface ReadSource {
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
readonly open: (
path: string,
options: { readonly flag: "r" },
) => Effect.Effect<
{
readonly stat: Effect.Effect<{ readonly type: FileType; readonly size: bigint | number }, FSUtil.Error>
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
},
FSUtil.Error,
Scope.Scope
>
}
const extensions = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => { const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
@@ -168,8 +110,7 @@ const mediaMime = (bytes: Uint8Array) => {
return "image/webp" return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
} }
const binary = (resource: string, bytes: Uint8Array) => { const binary = (bytes: Uint8Array) => {
if (extensions.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false if (bytes.length === 0) return false
let nonPrintable = 0 let nonPrintable = 0
for (const byte of bytes) { for (const byte of bytes) {
@@ -178,21 +119,11 @@ const binary = (resource: string, bytes: Uint8Array) => {
} }
return nonPrintable / bytes.length > 0.3 return nonPrintable / bytes.length > 0.3
} }
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) => const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
Effect.try({
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
catch: (error) => {
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
throw error
},
})
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* ( export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
input: string,
) {
const info = yield* fs.stat(input) const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
@@ -200,7 +131,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (
}) })
export const read = Effect.fn("ReadTool.read")(function* ( export const read = Effect.fn("ReadTool.read")(function* (
fs: ReadSource, fs: FSUtil.Interface,
input: string, input: string,
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
@@ -233,24 +164,25 @@ export const read = Effect.fn("ReadTool.read")(function* (
type: "file" as const, type: "file" as const,
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
content: Buffer.concat(chunks, total).toString("base64"), content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const, encoding: "base64" as const,
mime, mime,
} }
} }
if (extensions.has(path.extname(resource).toLowerCase()))
return yield* Effect.fail(new BinaryFileError({ resource }))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) { if (!paged) {
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource })) if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder("utf-8", { fatal: true }) const decoder = new TextDecoder()
const text = [yield* decodeUtf8(resource, decoder, first)] const text = [decodeUtf8(decoder, first)]
while (true) { while (true) {
const chunk = yield* file.readAlloc(64 * 1024) const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value)) text.push(yield* decodeChunk(resource, decoder, chunk.value))
} }
text.push(yield* decodeUtf8(resource, decoder)) text.push(decodeUtf8(decoder))
return { return {
type: "file" as const, type: "file" as const,
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
@@ -263,7 +195,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
const offset = page.offset || 1 const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES) const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = [] const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true }) const decoder = new TextDecoder()
let pending = "" let pending = ""
let discard = false let discard = false
let line = 1 let line = 1
@@ -321,8 +253,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
const newline = chunk.indexOf(10, start) const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1 const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end) const segment = chunk.subarray(start, end)
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource })) if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false if (!consume(decodeUtf8(decoder, segment))) return false
start = end start = end
} }
return true return true
@@ -334,7 +266,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
done = !(yield* consumeChunk(chunk.value)) done = !(yield* consumeChunk(chunk.value))
} }
if (!done) { if (!done) {
const tail = yield* decodeUtf8(resource, decoder) const tail = decodeUtf8(decoder)
if (!discard) pending += tail if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
} }
@@ -354,33 +286,28 @@ export const read = Effect.fn("ReadTool.read")(function* (
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real) const items = yield* fs.readDirectoryEntries(real)
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const absolute = path.join(real, item.name)
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!target || !FSUtil.contains(real, target)) return
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
})
}),
{ concurrency: 16 },
)
return sortAndPage(
entries.filter((item): item is FileSystem.Entry => item !== undefined),
page,
)
})
const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
const offset = page.offset || 1 const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES) const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = entries.sort(FileSystem.compareEntries) const visible = items
.flatMap((item) =>
item.type === "other"
? []
: [
ListEntry.make({
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
],
)
.sort((a, b) =>
a.type === "directory"
? b.type === "directory"
? a.path.localeCompare(b.path)
: -1
: b.type === "directory"
? 1
: a.path.localeCompare(b.path),
)
const selected = visible.slice(offset - 1, offset - 1 + limit) const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length const truncated = offset - 1 + selected.length < visible.length
return new ListPage({ return new ListPage({
@@ -389,7 +316,7 @@ const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
truncated, truncated,
...(truncated ? { next: offset + selected.length } : {}), ...(truncated ? { next: offset + selected.length } : {}),
}) })
} })
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@@ -404,118 +331,3 @@ const layer = Layer.effect(
) )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Reads through WorkspaceEnvironment.Files so hosted sessions never touch the
// host filesystem. The environment has no ranged read, so file reads transfer
// the whole file once and page in memory.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
// Absence surfaces as a platform NotFound so the read tool's missing-file
// handling stays backend-agnostic; other failures become filesystem errors.
const mapError =
(method: string) =>
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
: WorkspaceEnvironment.toFileSystemError(method)(error)
const stats = { stat: (target: string) => env.files.stat(target).pipe(Effect.mapError(mapError("stat"))) }
const source: ReadSource = {
// Paths arrive canonical from LocationMutation.resolve, so identity
// avoids a provider canonicalization round trip per read.
realPath: Effect.succeed,
// The environment has no ranged read, so each read call transfers the
// whole file once and pages in memory. Happy-path opens are that one
// transfer; only the failure path stats to classify a non-file target,
// which the shared read reports as PathKindError.
// TODO: Bound and page large hosted reads once environments expose file
// size and ranged reads.
open: (target) =>
env.files.read(target).pipe(
Effect.map((bytes) => {
let cursor = 0
return {
stat: Effect.succeed({ type: "File" as const, size: bytes.length }),
readAlloc: (size: number) =>
Effect.sync(() => {
if (cursor >= bytes.length) return Option.none<Uint8Array>()
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
cursor += chunk.length
return Option.some(chunk)
}),
}
}),
Effect.catchTag("WorkspaceEnvironment.Error", (error) =>
stats.stat(target).pipe(
Effect.catch(() => Effect.fail(mapError("read")(error))),
Effect.flatMap((info) =>
info.type === "File"
? Effect.fail(mapError("read")(error))
: Effect.succeed({
stat: Effect.succeed({ type: info.type, size: 0 }),
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
}),
),
),
),
Effect.catchTag("WorkspaceEnvironment.NotFoundError", (error) => Effect.fail(mapError("read")(error))),
),
}
// Matches the local list semantics: symlinks resolve and escape-filter
// against the listed directory. Non-symlink types come from the listing
// itself, keeping provider round trips proportional to symlink count.
const resolveSymlink = Effect.fnUntraced(function* (parent: string, name: string) {
const target = yield* env.files
.realPath(path.posix.join(parent, name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target === undefined || !FSUtil.containsPosix(parent, target)) return undefined
const info = yield* env.files.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "Directory") return "directory" as const
if (info?.type === "File") return "file" as const
return undefined
})
const list = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
const items = yield* env.files.list(input).pipe(Effect.mapError(mapError("list")))
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const type =
item.type === "file" || item.type === "directory"
? item.type
: item.type === "symlink"
? yield* resolveSymlink(input, item.name)
: undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? "/" : "")),
type,
})
}),
{ concurrency: 4 },
)
return sortAndPage(
entries.filter((item): item is FileSystem.Entry => item !== undefined),
page,
)
})
return Service.of({
inspect: (target) => inspect(stats, target),
read: (target, resource, page) => read(source, target, resource, page),
list,
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
+10 -2
View File
@@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
...Object.entries(info.agent ?? {}), ...Object.entries(info.agent ?? {}),
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const), ...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
] ]
if (!entries.length) return undefined const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : []))) const small = modelSelection(info.small_model)
if (!small) return entries.length ? result : undefined
return {
...result,
title: {
model: small,
...result.title,
},
}
} }
export function migrateAgent(info: ConfigAgentV1.Info) { export function migrateAgent(info: ConfigAgentV1.Info) {
-74
View File
@@ -1,80 +1,6 @@
export * as Workspace from "./workspace" export * as Workspace from "./workspace"
import { eq } from "drizzle-orm"
import { Context, Effect, Exit, Layer, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "./database/database"
import { WorkspaceDriver } from "./workspace/driver"
import { WorkspaceTable } from "./workspace/sql"
export const ID = Workspace.ID export const ID = Workspace.ID
export type ID = typeof ID.Type export type ID = typeof ID.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
provider: Schema.String,
root: AbsolutePath,
}).annotate({ identifier: "Workspace.Info" })
export interface Interface {
/** Allocate through the named driver and persist. Resolves when the Workspace is usable. */
readonly create: (input: {
readonly provider: string
}) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFoundError>
/** Metadata read; never contacts a driver. */
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
/** Provider key and opaque binding for WorkspaceDriver.connect. Hosted Location construction only. */
readonly binding: (
id: ID,
) => Effect.Effect<{ readonly provider: string; readonly binding: WorkspaceDriver.Binding }, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const registry = yield* WorkspaceDriver.RegistryService
const require = Effect.fn("Workspace.require")(function* (id: ID) {
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ id })
return row
})
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const driver = yield* registry.get(input.provider)
const id = ID.create()
const created = yield* Effect.acquireUseRelease(
driver.create({ workspaceID: id }),
(created) =>
db
.insert(WorkspaceTable)
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
.pipe(Effect.orDie, Effect.as(created)),
(created, exit) => (Exit.isFailure(exit) ? driver.destroy(created.binding).pipe(Effect.ignore) : Effect.void),
)
return Info.make({ id, provider: input.provider, root: AbsolutePath.make(created.root) })
}),
get: Effect.fn("Workspace.get")(function* (id) {
const row = yield* require(id)
return Info.make({ id: row.id, provider: row.provider, root: AbsolutePath.make(row.root) })
}),
binding: Effect.fn("Workspace.binding")(function* (id) {
const row = yield* require(id)
return { provider: row.provider, binding: row.binding }
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, WorkspaceDriver.registryNode] })
-70
View File
@@ -1,70 +0,0 @@
export * as WorkspaceDriver from "./driver"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
/**
* Smallest provider-owned JSON value required to reconnect to the same
* provider resource. Core stores it opaquely and hands it back; only the
* owning driver reads inside.
*/
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
provider: Schema.String,
message: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Interface {
/** Allocate a new environment. Resolves only when it is ready to use. */
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding; readonly root: string }, Error>
/**
* Binding -> live capabilities; the only way to obtain an environment.
* Fresh-create and restart-reconnect both flow through here. Closing the
* scope drops the connection, never the provider resource.
*/
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
/** Permanently release provider resources. */
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
}
/** Identity constructor so driver definitions get contextual typing. */
export const make = (driver: Interface) => driver
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
/** Immutable registry fixed at composition time. */
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = drivers[provider]
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFoundError({ provider }))
},
})
/**
* Defaults to no drivers, so every provider fails with ProviderNotFoundError.
* Server composition (or a test) replaces this binding with real drivers;
* core never constructs one.
*/
export const registryNode = makeGlobalNode({
service: RegistryService,
layer: Layer.succeed(RegistryService, RegistryService.of(registry({}))),
deps: [],
})
-133
View File
@@ -1,133 +0,0 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Workspace } from "../workspace"
import { WorkspaceDriver } from "./driver"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
/** Distinct so callers (e.g. the LocationMutation ancestor walk) can catch absence. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceEnvironment.NotFoundError", {
path: Schema.String,
}) {}
/** Translate environment failures into the host filesystem error vocabulary. */
export const toFileSystemError =
(method: string) =>
(cause: Error | NotFoundError): FSUtil.Error =>
new FSUtil.FileSystemError({ method, cause })
/**
* Wrap one driver promise, translating the driver's not-found signal into the
* environment error vocabulary so drivers and fakes never construct it ad hoc.
*/
export const tryOperation = <A>(input: {
readonly operation: string
readonly path: string
readonly run: () => Promise<A>
readonly isNotFound: (cause: unknown) => boolean
}): Effect.Effect<A, Error | NotFoundError> =>
Effect.tryPromise({
try: input.run,
catch: (cause) =>
input.isNotFound(cause)
? new NotFoundError({ path: input.path })
: new Error({ operation: input.operation, path: input.path, cause }),
})
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
/**
* The minimal primitive set the hosted implementations of Filesystem,
* LocationMutation, and FileMutation consume. Grow it only when a real
* consumer appears. Every method is one provider round trip: type mismatches
* (read a directory, list a file) fail with Error instead of requiring a
* stat pre-check.
*/
export interface Files {
readonly stat: (path: string) => Effect.Effect<FileInfo, Error | NotFoundError>
/** Canonical path with symlinks resolved; identity for permissions and locking. */
readonly realPath: (path: string) => Effect.Effect<string, Error | NotFoundError>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error | NotFoundError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error | NotFoundError>
/** Creates parent directories, matching FSUtil.writeWithDirs. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
/** Removes one file. Removing a missing path fails with NotFoundError. */
readonly remove: (path: string) => Effect.Effect<void, Error | NotFoundError>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
/** The Workspace root, absolute in the provider filesystem. */
readonly directory: string
readonly files: Files
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
/** NotFound becomes undefined; other environment failures become defects. */
export const optional = <A>(effect: Effect.Effect<A, Error | NotFoundError>): Effect.Effect<A | undefined> =>
effect.pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
/** Present only in hosted Location graphs; local graphs never bind it. */
export const node = LayerNode.unbound(Service, tags.values.location)
/**
* Connects on graph boot and stays connected for the graph's cached lifetime:
* Layer.effect supplies the build scope, so releasing the LayerMap entry
* closes the connection. It never stops or deletes the provider resource.
*/
export const hostedNode = (workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
// Connect failures during graph build are defects, matching the local
// graph's E = never. Typed availability errors are a later design.
layer: Layer.effect(
Service,
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const registry = yield* WorkspaceDriver.RegistryService
const found = yield* workspaces.binding(workspaceID)
const driver = yield* registry.get(found.provider)
return yield* driver.connect(found.binding)
}),
).pipe(Layer.orDie),
deps: [Workspace.node, WorkspaceDriver.registryNode],
})
/** Identity constructor so environment literals get contextual typing. */
export const make = (environment: Interface) => environment
/** Default lowering for Linux sandbox images. */
export const linuxShell: Shell = {
executable: "/bin/bash",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
}
-12
View File
@@ -1,12 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { WorkspaceDriver } from "./driver"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
root: text().notNull(),
...Timestamps,
})
+1
View File
@@ -126,6 +126,7 @@ describe("Agent", () => {
yield* agent.transform((editor) => editor.update(id, () => {})) yield* agent.transform((editor) => editor.update(id, () => {}))
const info = yield* agent.get(id) const info = yield* agent.get(id)
expect(info?.mode).toBe("primary")
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual( expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions, Agent.Info.default(id).permissions,
) )
+5
View File
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () => it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test") const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect( expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow") ).toBe("allow")
+14
View File
@@ -512,6 +512,20 @@ describe("Config", () => {
}), }),
) )
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
it.effect("migrates v1 provider lists to policies", () => it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => { Effect.sync(() => {
expect( expect(
@@ -149,6 +149,28 @@ describe("ConfigNormalize", () => {
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow() expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
}) })
test("migrates the legacy small model to the title agent", () => {
const result = normalized({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
})
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
},
})
expect(result.diagnostics).toEqual([])
})
test("omits an invalid legacy small model without exposing its value", () => {
const secret = "do-not-log-this-value"
const result = normalized({ small_model: secret })
expect(result.encoded.agents).toBeUndefined()
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("recovers malformed named entries and retains a valid legacy collision", () => { test("recovers malformed named entries and retains a valid legacy collision", () => {
const result = normalized({ const result = normalized({
command: { fallback: { template: "legacy" } }, command: { fallback: { template: "legacy" } },
@@ -390,7 +412,6 @@ describe("ConfigNormalize", () => {
const secret = "do-not-log-this-value" const secret = "do-not-log-this-value"
const result = normalized({ const result = normalized({
logLevel: "DEBUG", logLevel: "DEBUG",
small_model: secret,
agent: { reviewer: { name: secret, prompt: "review" } }, agent: { reviewer: { name: secret, prompt: "review" } },
provider: { provider: {
custom: { custom: {
@@ -409,7 +430,6 @@ describe("ConfigNormalize", () => {
}) })
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([ expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["logLevel"], ["logLevel"],
["small_model"],
["agent", "reviewer", "name"], ["agent", "reviewer", "name"],
["provider", "custom", "id"], ["provider", "custom", "id"],
["provider", "custom", "whitelist"], ["provider", "custom", "whitelist"],
+9 -37
View File
@@ -43,10 +43,9 @@ describe("FileMutation", () => {
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({ expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write", operation: "write",
target: target.canonical, target: target.absolute,
resource: "hello.txt", resource: "hello.txt",
existed: true, existed: true,
content: "after",
}) })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after") expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -63,12 +62,11 @@ describe("FileMutation", () => {
expect(result).toEqual({ expect(result).toEqual({
operation: "write", operation: "write",
target: target.canonical, target: target.absolute,
resource: "src/nested/hello.txt", resource: "src/nested/hello.txt",
existed: false, existed: false,
content: "hello",
}) })
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello") expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
) )
@@ -82,36 +80,11 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" }) const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
yield* files.write({ target: preserved, content: "\uFEFFafter" }) yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
yield* files.write({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" }) yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated") expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
),
)
it.live("moves a file and transfers its BOM to the destination", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fromPath = path.join(directory, "from.txt")
yield* Effect.promise(() => fs.writeFile(fromPath, "\uFEFFbefore"))
const mutation = yield* LocationMutation.Service
const from = yield* mutation.resolve({ path: "from.txt" })
const to = yield* mutation.resolve({ path: "to.txt" })
const result = yield* (yield* FileMutation.Service).move({ from, to, content: "after" })
expect(result.content).toBe("after")
expect(yield* Effect.promise(() => fs.readFile(to.canonical, "utf8"))).toBe("\uFEFFafter")
expect(
yield* Effect.promise(() =>
fs.access(fromPath).then(
() => true,
() => false,
),
),
).toBe(false)
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
) )
@@ -126,10 +99,9 @@ describe("FileMutation", () => {
expect(result).toEqual({ expect(result).toEqual({
operation: "write", operation: "write",
target: target.canonical, target: target.absolute,
resource: target.resource, resource: target.resource,
existed: false, existed: false,
content: "external",
}) })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external") expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -137,7 +109,7 @@ describe("FileMutation", () => {
), ),
) )
it.live("serializes concurrent writes to the same canonical target", () => it.live("serializes concurrent writes to the same absolute target", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt") const targetPath = path.join(directory, "shared.txt")
@@ -180,7 +152,7 @@ describe("FileMutation", () => {
), ),
) )
it.live("allows distinct canonical targets to proceed independently", () => it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>() const firstStarted = yield* Deferred.make<void>()
-161
View File
@@ -1,161 +0,0 @@
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "fs/promises"
import nodePath from "path"
import { Effect, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
export const ROOT = "/workspace"
export const workspaceID = Workspace.ID.make("wrk_test")
/** A hosted Location at the workspace root, as hostedBoundNode would state it. */
export const hostedLocationLayer = (root: string = ROOT) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: AbsolutePath.make(root),
workspaceID,
project: Project.hostedGlobal(root),
}),
)
export interface MemoryEnvironment {
readonly environment: WorkspaceEnvironment.Interface
/** Live view of stored file contents by absolute path. */
readonly contents: (path: string) => string | undefined
}
/**
* Environment backed by a real host directory and spawner: the honest fake
* for end-to-end tests. Files go through fs/promises, commands actually run
* with the given spawner, so bash output is visible to reads.
*/
export const directoryEnvironment = (
root: string,
spawn: ChildProcessSpawner["Service"]["spawn"],
): WorkspaceEnvironment.Interface => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
WorkspaceEnvironment.tryOperation({
operation,
path,
run,
isNotFound: (cause) => (cause as NodeJS.ErrnoException).code === "ENOENT",
})
return WorkspaceEnvironment.make({
directory: root,
files: {
stat: (path) =>
wrap("stat", path, async () => {
const info = await stat(path)
return {
type: info.isFile()
? ("File" as const)
: info.isDirectory()
? ("Directory" as const)
: ("Unknown" as const),
}
}),
realPath: (path) => wrap("realPath", path, () => realpath(path)),
read: (path) => wrap("read", path, async () => Uint8Array.from(await readFile(path))),
list: (path) =>
wrap("list", path, async () => {
const entries = await readdir(path, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
type: entry.isFile()
? ("file" as const)
: entry.isDirectory()
? ("directory" as const)
: entry.isSymbolicLink()
? ("symlink" as const)
: ("other" as const),
}))
}),
write: (path, content) =>
Effect.tryPromise({
try: async () => {
await mkdir(nodePath.dirname(path), { recursive: true })
await writeFile(path, content)
},
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => rm(path)),
},
process: make(spawn),
shell: {
executable: "/bin/bash",
args: (command) => ["-c", command],
// Real commands need PATH; nothing else from the host environment leaks.
environmentOverrides: { PATH: process.env.PATH ?? "" },
detached: false,
},
})
}
/**
* In-memory workspace environment rooted at /workspace: file paths to
* contents, directories implied by keys, no symlinks, no processes. Type
* mismatches (read a directory, list a file) fail with Error per the Files
* contract.
*/
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))]))
const isDirectory = (path: string) =>
path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/"))
const exists = (path: string) => store.has(path) || isDirectory(path)
const notFound = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
const wrongType = (operation: string, path: string) =>
Effect.fail(new WorkspaceEnvironment.Error({ operation, path }))
const environment = WorkspaceEnvironment.make({
directory: ROOT,
files: {
stat: (path) =>
store.has(path)
? Effect.succeed({ type: "File" as const })
: isDirectory(path)
? Effect.succeed({ type: "Directory" as const })
: notFound(path),
realPath: (path) => (exists(path) ? Effect.succeed(path) : notFound(path)),
read: (path) => {
const content = store.get(path)
if (content) return Effect.succeed(content)
return isDirectory(path) ? wrongType("read", path) : notFound(path)
},
list: (path) => {
if (store.has(path)) return wrongType("list", path)
if (!isDirectory(path)) return notFound(path)
const names = new Map<string, "file" | "directory">()
for (const key of store.keys()) {
if (!key.startsWith(path + "/")) continue
const rest = key.slice(path.length + 1)
const [head] = rest.split("/")
if (head) names.set(head, rest.includes("/") ? "directory" : "file")
}
return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type })))
},
write: (path, content) =>
Effect.sync(() => {
store.set(path, Uint8Array.from(content))
}),
remove: (path) => {
if (store.delete(path)) return Effect.void
return isDirectory(path) ? wrongType("remove", path) : notFound(path)
},
},
process: make(() => Effect.die(new Error("no processes in the memory environment"))),
shell: WorkspaceEnvironment.linuxShell,
})
return {
environment,
contents: (path) => {
const stored = store.get(path)
return stored ? decoder.decode(stored) : undefined
},
}
}
+16 -48
View File
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: yield* Effect.promise(() => fs.realpath(targetPath)), absolute: targetPath,
resource: "hello.txt", resource: "hello.txt",
}) })
expect(target.externalDirectory).toBeUndefined() expect(target.externalDirectory).toBeUndefined()
@@ -50,10 +50,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") }) const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const root = yield* Effect.promise(() => fs.realpath(directory))
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: path.join(root, "src", "new.txt"), absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt", resource: "src/new.txt",
}) })
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -64,9 +62,9 @@ describe("LocationMutation", () => {
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory))) const root = path.dirname(directory)
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: path.join(root, "outside.txt"), absolute: path.join(root, "outside.txt"),
resource: path.join(root, "outside.txt").replaceAll("\\", "/"), resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
}) })
expect(target.externalDirectory).toMatchObject({ expect(target.externalDirectory).toMatchObject({
@@ -77,7 +75,7 @@ describe("LocationMutation", () => {
), ),
) )
it.live("authorizes a prospective target below an external symlink by its in-location path", () => it.live("resolves a prospective target below an external symlink lexically", () =>
withTmp((directory) => { withTmp((directory) => {
const outside = `${directory}-outside` const outside = `${directory}-outside`
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -88,7 +86,7 @@ describe("LocationMutation", () => {
}) })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }) const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"), absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt", resource: "escape/new.txt",
}) })
expect(target.externalDirectory).toBeUndefined() expect(target.externalDirectory).toBeUndefined()
@@ -107,7 +105,7 @@ describe("LocationMutation", () => {
}) })
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({ expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"), absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt", resource: "linked/new.txt",
}) })
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -120,7 +118,7 @@ describe("LocationMutation", () => {
const targetPath = path.join(directory, "new.txt") const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"), absolute: targetPath,
resource: "new.txt", resource: "new.txt",
}) })
expect(target.externalDirectory).toBeUndefined() expect(target.externalDirectory).toBeUndefined()
@@ -134,9 +132,9 @@ describe("LocationMutation", () => {
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt") const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside)) const root = outside
expect(target).toMatchObject({ expect(target).toMatchObject({
canonical: path.join(root, "new.txt"), absolute: path.join(root, "new.txt"),
resource: path.join(root, "new.txt").replaceAll("\\", "/"), resource: path.join(root, "new.txt").replaceAll("\\", "/"),
}) })
expect(target.externalDirectory).toMatchObject({ expect(target.externalDirectory).toMatchObject({
@@ -155,53 +153,23 @@ describe("LocationMutation", () => {
const targetPath = path.join(outside, "existing.txt") const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing")) yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside)) expect(target).toMatchObject({ absolute: targetPath })
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") }) expect(target.externalDirectory?.directory).toBe(outside)
expect(target.externalDirectory?.directory).toBe(root)
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
), ),
) )
it.live("authorizes an external symlink by the named path", () => it.live("authorizes prospective external descendants at their lexical parent", () =>
withTmp((directory) =>
withTmp((outside) =>
withTmp((referent) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const targetPath = path.join(referent, "target.txt")
const linkPath = path.join(outside, "link.txt")
yield* Effect.promise(async () => {
await fs.writeFile(targetPath, "content")
await fs.symlink(targetPath, linkPath)
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: linkPath })
expect(target).toMatchObject({
absolute: linkPath,
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
resource: linkPath.replaceAll("\\", "/"),
externalDirectory: {
directory: outside,
resource: path.join(outside, "*").replaceAll("\\", "/"),
},
})
}).pipe(provide(directory)),
),
),
),
)
it.live("anchors prospective external descendants at their stable existing directory", () =>
withTmp((directory) => withTmp((directory) =>
withTmp((outside) => withTmp((outside) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt") const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside)) const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({ expect(target.externalDirectory).toMatchObject({
directory: root, directory: parent,
resource: path.join(root, "*").replaceAll("\\", "/"), resource: path.join(parent, "*").replaceAll("\\", "/"),
}) })
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
+8 -4
View File
@@ -3,6 +3,7 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node)) const it = testEffect(LayerNode.compile(PtyTicket.node))
@@ -45,14 +46,17 @@ describe("PTY websocket tickets", () => {
}), }),
) )
it.live("rejects tickets scoped to a different pty", () => it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () { Effect.gen(function* () {
const tickets = yield* PtyTicket.Service const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending() const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID }) const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID: PtyID.ascending(), ticket: issued.ticket })).toBe(false) expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true) false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}), }),
) )
}) })
+4 -2
View File
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -126,6 +127,7 @@ describe("Session.create", () => {
it.effect("stores supplied immutable create attributes", () => it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({ const model = Model.Ref.make({
id: Model.ID.make("sonnet"), id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic, providerID: Provider.ID.anthropic,
@@ -134,11 +136,11 @@ describe("Session.create", () => {
expect( expect(
yield* session.create({ yield* session.create({
location: Location.Ref.make({ directory: location.directory }), location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
model, model,
}), }),
).toMatchObject({ location: { directory: location.directory }, agent: "build", model }) ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
}), }),
) )
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Identifier } from "@opencode-ai/core/id/id"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
info = new Info(),
) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([new Document({ type: "document", info })]),
changes: () => Stream.empty,
}),
)
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
[Config.node, config],
[Global.node, Global.layerWith({ data: tmp.path })],
])
return Effect.gen(function* () {
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
describe("ToolOutput", () => {
it.live("writes oversized text and returns a bounded preview", () =>
withStore(
(service, fs) =>
Effect.gen(function* () {
const output = { items: [1, 2, 3] }
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
expect(result.output).toBe(output)
expect(result.metadata).toMatchObject({ truncated: true })
const outputPath = result.metadata?.outputPath
expect(typeof outputPath).toBe("string")
if (typeof outputPath !== "string") return
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
expect(result.content).toEqual([
{ type: "text", text: "one\ntwo" },
{ type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
)
it.live("reports bytes omitted by the byte limit", () =>
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\ntwo" })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{
type: "text",
text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
},
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
),
)
it.live("preserves mixed content ordering", () =>
withStore(
(output) =>
Effect.gen(function* () {
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
const result = yield* output.truncate({
content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
})
expect(result.content).toEqual([
{ type: "text", text: "before" },
file,
{ type: "text", text: "after" },
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
)
it.live("skips results that report a truncation state", () =>
withStore((output) =>
Effect.gen(function* () {
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
expect(yield* output.truncate(truncated)).toBe(truncated)
expect(yield* output.truncate(retained)).toBe(retained)
}),
),
)
it.live("marks results that fit without changing their content", () =>
withStore((output) =>
Effect.gen(function* () {
const content = [{ type: "text" as const, text: "small" }]
expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
}),
),
)
it.live("does not count a trailing newline as another line", () =>
withStore(
(output) =>
Effect.gen(function* () {
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
content: "one\ntwo\n",
metadata: { truncated: false },
})
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
),
)
it.live("reports a trailing newline omitted by the byte limit", () =>
withStore(
(output) =>
Effect.gen(function* () {
const result = yield* output.truncate({ content: "one\n" })
expect(result.content).toEqual([
{ type: "text", text: "one" },
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
])
}),
new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
),
)
it.live("removes expired managed files", () =>
withStore((output, fs, root) =>
Effect.gen(function* () {
const directory = path.join(root, ToolOutput.DIRECTORY)
const old = path.join(
directory,
Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000),
)
const recent = path.join(directory, Identifier.ascending("tool"))
yield* fs.ensureDir(directory)
yield* fs.writeFileString(old, "old")
yield* fs.writeFileString(recent, "recent")
yield* output.cleanup()
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
}),
),
)
})
+3 -25
View File
@@ -13,8 +13,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch" import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -24,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({ const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin", name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node], deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_patch_tool_test") const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -606,24 +604,6 @@ describe("PatchTool", () => {
), ),
) )
it.live("preserves a BOM when moving a file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "source.txt")
const moved = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(source, "\uFEFFbefore\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: source.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("\uFEFFafter\n")
}),
),
)
it.live("rejects an update with missing context", () => it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) => withTempTool((directory, registry) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -848,9 +828,7 @@ describe("PatchTool", () => {
), ),
) )
// Paths outside the active Location require external approval — the same it.live("treats a sibling path inside the project worktree as internal", () =>
// boundary the edit and write tools derive from LocationMutation.
it.live("requires external approval for sibling paths outside the location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
@@ -869,7 +847,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
), ),
).toMatchObject({ status: "completed" }) ).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}), }),
tmp.path, tmp.path,
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, FileSystem } from "effect" import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@@ -50,22 +51,53 @@ describe("ReadToolFileSystem", () => {
}), }),
) )
it.effect("reports binary and malformed UTF-8 content as typed errors", () => it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { fs, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat") const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt") const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97) yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
malformedContent[64 * 1024] = 0x80
yield* files.writeFile(malformed, malformedContent)
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip) const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip) const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat") expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error) expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
}),
)
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
)
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
{ path: "escape", type: "symlink" },
{ path: "file.txt", type: "file" },
])
}), }),
) )

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