Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6028df564 | |||
| d2843392c8 | |||
| fe63467a4d | |||
| 77cf46721f | |||
| 3ab34035e2 | |||
| 061ca12ec5 | |||
| 9f719059ab | |||
| 37561e4347 | |||
| a59bd3e0bc | |||
| 05e3c4ecee | |||
| 72d008bd5c | |||
| 202ec12056 | |||
| af30b315b2 | |||
| 425a4f584f | |||
| 7504daa602 | |||
| 9ac0f3e9ac | |||
| cf2cd13fb8 | |||
| 6883309997 | |||
| c3562a4a51 | |||
| 30a66dfc31 | |||
| fdc574ff81 | |||
| 1e5ddbd812 | |||
| 3109060216 | |||
| 9031ce7b51 | |||
| 2449b50585 | |||
| 66e6662440 | |||
| 56f3b9044d | |||
| ec58afe5be | |||
| 913659890d | |||
| 7bba9ed4d4 | |||
| b1a4f99181 | |||
| b0f6dc506b | |||
| 14e0b9b17f | |||
| 146cce195a | |||
| e5524f5bf9 | |||
| 16234a0baf | |||
| e5cf3ad3c2 | |||
| 82da3320ba | |||
| aa553dea94 | |||
| 0c9cad86ec | |||
| 69910f361c | |||
| 7bafbb309a | |||
| ec26d78450 | |||
| 5cfb70bff0 | |||
| 49e8d324dd | |||
| 60b9ba4db7 | |||
| a1a401dc9b | |||
| 2f1547ebaf | |||
| 62da1e7682 | |||
| 28a06e52fc | |||
| 5a5d981c4e | |||
| 62a7781e04 | |||
| 2d03cf4a9a | |||
| 4025d30b24 | |||
| dd3c443d8c | |||
| 4aaa9f9e30 | |||
| 31c805bc97 | |||
| 94f2ed1b84 | |||
| 86dc66eae9 | |||
| 9814dc6526 | |||
| f09c859974 | |||
| 1fcdb0246a | |||
| 340ad9e9ed | |||
| d73995a73a | |||
| 91ff955757 | |||
| d9ee222c99 | |||
| 1462f9caf3 | |||
| ebaad55c8c | |||
| f5c770d65e | |||
| 27d6920d60 | |||
| 930526ddcd | |||
| dd4aa2ab5a | |||
| e03ffb9ed4 | |||
| c9426d3a57 | |||
| eb5d30257a | |||
| 939426828d | |||
| 1499daf15f | |||
| 540525e98e | |||
| 5334fd6b81 | |||
| 4cf3eb04cd | |||
| 571ae48e14 | |||
| 9a0a1a801e | |||
| d34eca2f0d | |||
| 5b1085d229 | |||
| 1f053c4942 | |||
| c25128a6f5 | |||
| 24277b51e3 | |||
| 26f64f29fb | |||
| 13822b9424 | |||
| 9332560ffd | |||
| 5aad254f68 | |||
| 0ba2bea40c | |||
| e16115f53f | |||
| 26bf234228 | |||
| 148b3cf034 | |||
| de69c5af0a | |||
| e5a4f6f603 | |||
| 4e8020b503 | |||
| 5778008596 | |||
| b70fc22a4b | |||
| affbea691b | |||
| cecaf82445 | |||
| 777169ac17 | |||
| 58f569fbf7 | |||
| 7dd8b2b7f9 | |||
| fcb4bc1b3a | |||
| 6ada6e0db9 | |||
| cc1d7e5d5b | |||
| 013bd4c918 | |||
| 4799efb9bf | |||
| 2eb3072e3e | |||
| 9cd611780f | |||
| 70443cd2de | |||
| ca8e6e4d67 | |||
| 7bcb519e0c | |||
| 4f6caacde4 | |||
| 81e907febe | |||
| ed4a41f1e0 | |||
| cea9a9905d | |||
| 09e4e5a184 | |||
| 2e0b993d5d | |||
| babaf781da | |||
| a17ce350f1 | |||
| 0e04141849 | |||
| 29130af9ec | |||
| 9235753aa0 | |||
| 796991b3ea | |||
| 6ae05c541d | |||
| bc84698428 | |||
| e3d2a9ddbb | |||
| 3d26bbed82 | |||
| 3ad63536bf | |||
| f347d9a58a | |||
| 3822c0aaec | |||
| 16ada93dd4 | |||
| 6fc5f342dd | |||
| 0e7e791008 | |||
| 11528e43c0 | |||
| 4560435dd9 | |||
| 3349fb95ca | |||
| bff9e576b7 | |||
| da2e640029 | |||
| 8fd7bd19d6 | |||
| 902ac2dad9 | |||
| 33f5b80235 | |||
| d04d13ea22 | |||
| cfcc6f1353 | |||
| 3360480a2a | |||
| 3e7e709884 | |||
| c510661ef3 | |||
| 12fa782137 | |||
| 66dfdb933d | |||
| 3b1970a0f4 | |||
| d8b1d86092 | |||
| d7111a7072 | |||
| 17bd7ffbf1 | |||
| 9fa3a99480 | |||
| 4318b0902d | |||
| 06b27db78e | |||
| fcc9cab760 | |||
| 0dae445f4f | |||
| 15092be204 | |||
| 482ef946fe | |||
| 421f3bbd0b | |||
| 76da54c596 | |||
| 1baa87bf0d | |||
| 8554345ba0 | |||
| b321a2de2b | |||
| 2cd61113c1 | |||
| c1dc769b5f | |||
| 08d422dca1 | |||
| 6f2a6356ed | |||
| 58ab95e32c | |||
| 5aa544179d | |||
| 482dc3a15d | |||
| 710469cf09 | |||
| e897883f91 | |||
| 8d8e8fe8f4 | |||
| df635562e9 | |||
| c575415ec4 | |||
| c0e449d3ac | |||
| ec1a5c261e | |||
| 4212931808 | |||
| 1a215700c8 | |||
| 9d4737fdc9 | |||
| d6f2b9f1b7 | |||
| 8af7b5cd65 | |||
| ba145fd61a | |||
| 02a8711f48 | |||
| 41a6067a04 | |||
| 0f92cec469 | |||
| 5acfe3a20c | |||
| 731c1e58f2 | |||
| 4ef581c111 | |||
| a888bf2194 | |||
| f8a23b428d | |||
| c92aaf8b80 | |||
| 79552d31e6 | |||
| 885bb25f13 | |||
| 987afa3d71 | |||
| a3f5c5d14d | |||
| c411d37484 | |||
| da2e91cf60 | |||
| f327584e0a | |||
| 97a94571a4 | |||
| 6652585a7f | |||
| 532b64c0d5 | |||
| eec4c775a7 | |||
| 01e350449c | |||
| 5792a80a8c | |||
| db039db7f5 | |||
| c1a3936b61 | |||
| a9d9e4d9c4 | |||
| 2531b2d3a9 | |||
| a718f86e0f | |||
| f3efdff861 | |||
| 955d8591df | |||
| 33b3388bf4 | |||
| 716f40b128 | |||
| 0b06ff1407 | |||
| 01ff5b5390 | |||
| 3d1b121e70 | |||
| b70629af27 | |||
| b7b016fa28 | |||
| 5ba2d7e5f0 | |||
| 459b22b83d | |||
| 377812b98a | |||
| 5cc0901e38 | |||
| 7fb6b589d1 | |||
| 3f37b43e7d | |||
| 8805dfc849 | |||
| ac5a5d8b16 | |||
| eaf94ed047 | |||
| b8031c5ae8 | |||
| a531f3f36d | |||
| bb3382311d | |||
| ad545d0cc9 | |||
| ac244b1458 | |||
| f202536b65 | |||
| 405cc3f610 | |||
| 878c1b8c2d | |||
| d5dcadc000 | |||
| 0c154e6a2f | |||
| 4f96975148 | |||
| eaba99711b | |||
| f762125775 | |||
| ded6bb6513 | |||
| 39332f5be6 | |||
| 2c6ff35400 | |||
| 738d6c8899 |
@@ -478,6 +478,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -519,6 +520,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -575,9 +577,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15",
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -824,9 +826,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1716,23 +1718,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -5138,7 +5140,7 @@
|
||||
|
||||
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
@@ -5496,6 +5498,8 @@
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
@@ -5958,6 +5962,8 @@
|
||||
|
||||
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
|
||||
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
|
||||
|
||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
@@ -6074,6 +6080,8 @@
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
+2
-2
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.5 vCPU",
|
||||
memory: "1 GB",
|
||||
cpu: "1 vCPU",
|
||||
memory: "4 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
|
||||
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
|
||||
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
|
||||
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
|
||||
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
|
||||
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
|
||||
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
|
||||
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun run --cwd packages/stats/app dev",
|
||||
"dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
@@ -38,9 +38,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
+29
-24
@@ -43,6 +43,7 @@ import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { WslServersProvider } from "@/context/wsl-servers"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
@@ -71,7 +72,7 @@ declare global {
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
activeServer?: string
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
@@ -171,11 +172,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
}}
|
||||
>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
@@ -298,11 +301,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
{(key) => props.children(key)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -319,22 +322,24 @@ export function AppInterface(props: {
|
||||
<ServersProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ServerKey>
|
||||
<QueryProvider>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryProvider>
|
||||
{() => (
|
||||
<QueryProvider>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryProvider>
|
||||
)}
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServersProvider>
|
||||
|
||||
@@ -5,20 +5,26 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { DialogWslServer } from "@/components/dialog-wsl-server"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface DialogSelectServerProps {
|
||||
onNavigateHome?: () => void
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
name: string
|
||||
@@ -27,7 +33,6 @@ interface ServerFormProps {
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onNameChange: (value: string) => void
|
||||
onUsernameChange: (value: string) => void
|
||||
@@ -44,15 +49,17 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
|
||||
return conn.type === "sidecar" && conn.variant === "wsl"
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
const [defaultKey, defaultActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -60,52 +67,18 @@ function useDefaultServer() {
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
defaultActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey, 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) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -171,15 +144,18 @@ function ServerForm(props: ServerFormProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const navigate = useNavigate()
|
||||
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const wslServers = useWslServers()
|
||||
const defaultServer = useDefaultServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
@@ -189,7 +165,9 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
addWsl: {
|
||||
showWizard: false,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
@@ -198,7 +176,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -210,7 +187,6 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
@@ -221,7 +197,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +269,32 @@ export function DialogSelectServer() {
|
||||
},
|
||||
}))
|
||||
|
||||
const removeWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.removeServer(key)
|
||||
return key
|
||||
},
|
||||
onSuccess: async (key) => {
|
||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
||||
server.remove(key)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const retryWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.startServer(key)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const updateWslMutation = useMutation(() => ({
|
||||
mutationFn: async (distro: string) => {
|
||||
await platform.wslServers?.installOpencode(distro)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
@@ -312,6 +313,32 @@ export function DialogSelectServer() {
|
||||
})
|
||||
|
||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||
const wslState = () => wslServers.data
|
||||
const healthPollKey = createMemo(() =>
|
||||
items()
|
||||
.map((conn) =>
|
||||
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
|
||||
)
|
||||
.join("\n\n"),
|
||||
)
|
||||
const health = (key: ServerConnection.Key) => store.status[key]
|
||||
const wslRuntime = (conn: ServerConnection.Any) => {
|
||||
if (!isWslSidecar(conn)) return
|
||||
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
|
||||
}
|
||||
const nonReadyWslServers = createMemo(() =>
|
||||
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
|
||||
)
|
||||
const canRetryWsl = (conn: ServerConnection.Any) => {
|
||||
const runtime = wslRuntime(conn)
|
||||
return runtime?.kind === "failed" || runtime?.kind === "stopped"
|
||||
}
|
||||
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
|
||||
const wslRuntimeLabel = (kind: string) => {
|
||||
if (kind === "starting") return "Starting"
|
||||
if (kind === "failed") return "Failed"
|
||||
return "Stopped"
|
||||
}
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
@@ -326,7 +353,7 @@ export function DialogSelectServer() {
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
@@ -334,39 +361,60 @@ export function DialogSelectServer() {
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
const list = untrack(items)
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (disposed) return
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
items()
|
||||
healthPollKey()
|
||||
void refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const wslCheck = (conn: ServerConnection.Any) => {
|
||||
if (!isWslSidecar(conn)) return null
|
||||
return wslState()?.opencodeChecks[conn.distro] ?? null
|
||||
}
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
|
||||
const nextKey = ServerConnection.key(conn)
|
||||
const changed = server.key !== nextKey
|
||||
|
||||
const navigateHome = () => props.onNavigateHome?.()
|
||||
|
||||
const apply = () => {
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigateHome()
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
navigateHome()
|
||||
server.setActive(nextKey)
|
||||
})
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
await apply()
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
|
||||
apply()
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -377,25 +425,16 @@ export function DialogSelectServer() {
|
||||
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) => {
|
||||
@@ -406,20 +445,15 @@ export function DialogSelectServer() {
|
||||
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">(() => {
|
||||
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
|
||||
if (store.addWsl.showWizard) return "add-wsl"
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
@@ -433,9 +467,11 @@ export function DialogSelectServer() {
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
setStore("addWsl", "showWizard", false)
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
setStore("addWsl", "showWizard", false)
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
@@ -444,11 +480,11 @@ export function DialogSelectServer() {
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
setStore("addWsl", "showWizard", false)
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
@@ -457,10 +493,22 @@ export function DialogSelectServer() {
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const startAddWsl = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
setStore("addWsl", "showWizard", true)
|
||||
}
|
||||
|
||||
const handleAddedWsl = async (distro: string) => {
|
||||
const key = ServerConnection.Key.make(`wsl:${distro}`)
|
||||
setStore("addWsl", "showWizard", false)
|
||||
const conn = items().find((item) => ServerConnection.key(item) === key)
|
||||
if (conn) await select(conn)
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
@@ -477,14 +525,22 @@ export function DialogSelectServer() {
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const isAddWslMode = createMemo(() => mode() === "add-wsl")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
|
||||
|
||||
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>
|
||||
<span>
|
||||
{isAddWslMode()
|
||||
? "Add WSL server"
|
||||
: isAddMode()
|
||||
? language.t("dialog.server.add.title")
|
||||
: language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -495,37 +551,126 @@ export function DialogSelectServer() {
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
}
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
server.remove(key)
|
||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={formTitle()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-2">
|
||||
<Dialog
|
||||
title={formTitle()}
|
||||
fit={isAddWslMode()}
|
||||
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
|
||||
>
|
||||
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
|
||||
<Show
|
||||
when={!isFormMode()}
|
||||
fallback={
|
||||
<ServerForm
|
||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={formBusy()}
|
||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||
onSubmit={submitForm}
|
||||
onBack={resetForm}
|
||||
/>
|
||||
<Show
|
||||
when={isAddWslMode()}
|
||||
fallback={
|
||||
<ServerForm
|
||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={formBusy()}
|
||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||
onSubmit={submitForm}
|
||||
onBack={resetForm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DialogWslServer onAdded={handleAddedWsl} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={nonReadyWslServers().length > 0}>
|
||||
<div class="px-5">
|
||||
<div class="bg-surface-base rounded-md overflow-hidden">
|
||||
<For each={nonReadyWslServers()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.Key.make(item.config.id)
|
||||
const retryable = () => canRetryWslRuntime(item.runtime.kind)
|
||||
return (
|
||||
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-critical-base": item.runtime.kind === "failed",
|
||||
"bg-border-weak-base": item.runtime.kind !== "failed",
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
|
||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
||||
WSL
|
||||
</span>
|
||||
<Show when={defaultServer.defaultKey() === key}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-12-regular text-text-weak truncate">
|
||||
{wslRuntimeLabel(item.runtime.kind)}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<Show when={retryable()}>
|
||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={retryable() || defaultServer.canDefault()}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => removeWslMutation.mutate(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<List
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
@@ -534,7 +679,7 @@ export function DialogSelectServer() {
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x.http.url}
|
||||
key={(x) => ServerConnection.key(x)}
|
||||
onSelect={(x) => {
|
||||
if (x) void select(x)
|
||||
}}
|
||||
@@ -543,18 +688,35 @@ export function DialogSelectServer() {
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
const wsl = isWslSidecar(i)
|
||||
const wslDistro = wsl ? i.distro : undefined
|
||||
const blocked = () => health(key)?.healthy === false
|
||||
const canChangeDefault = () => defaultServer.canDefault() && (i.type === "http" || wsl)
|
||||
const canRemove = () => i.type === "http" || wsl
|
||||
const opencodeAction = () => {
|
||||
const check = wslCheck(i)
|
||||
if (!check) return null
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||
return null
|
||||
}
|
||||
const updating = () => {
|
||||
const job = wslState()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === wslDistro
|
||||
}
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-start w-5">
|
||||
<ServerHealthIndicator health={store.status[key]} />
|
||||
<ServerHealthIndicator health={health(key)} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
status={store.status[key]}
|
||||
dimmed={blocked()}
|
||||
status={health(key)}
|
||||
version={wslCheck(i)?.version ?? undefined}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -562,12 +724,32 @@ export function DialogSelectServer() {
|
||||
}
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<div class="flex items-center justify-center gap-3 pl-4">
|
||||
<Show when={wsl && opencodeAction()}>
|
||||
{(label) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!!wslState()?.job}
|
||||
class="shrink-0"
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (wslDistro) updateWslMutation.mutate(wslDistro)
|
||||
}}
|
||||
>
|
||||
<Show when={updating()}>
|
||||
<Spinner class="size-3.5 shrink-0" />
|
||||
</Show>
|
||||
{label()}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
<Show when={i.type === "http"}>
|
||||
<Show when={i.type === "http" || i.type === "sidecar"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
@@ -579,35 +761,54 @@ export function DialogSelectServer() {
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<Show when={i.type === "http"}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={wsl && canRetryWsl(i)}>
|
||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<Show when={canRemove()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (wsl) {
|
||||
removeWslMutation.mutate(key)
|
||||
return
|
||||
}
|
||||
void handleRemove(key)
|
||||
}}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -621,17 +822,32 @@ export function DialogSelectServer() {
|
||||
|
||||
<div class="shrink-0 px-5 pb-5">
|
||||
<Show
|
||||
when={isFormMode()}
|
||||
when={!isAddWslMode() && isFormMode()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<Show when={!isAddWslMode()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<Show when={canAddWsl()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAddWsl}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
Add WSL
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
|
||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
}
|
||||
|
||||
interface DialogWslServerProps {
|
||||
onAdded?: (distro: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const api = platform.wslServers!
|
||||
const [store, setStore] = createStore({
|
||||
step: undefined as WslServerStep | undefined,
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
adding: false,
|
||||
})
|
||||
const current = () => wslServers.data
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const selectedProbe = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return current()?.distroProbes[distro] ?? null
|
||||
})
|
||||
const selectedInstalled = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
if (distroReady()) return null
|
||||
return probe
|
||||
})
|
||||
const distroUnavailableMessage = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
const distro = store.selectedDistro
|
||||
if (!probe || probe.canExecute || !distro) return null
|
||||
if (!selectedInstalled()) return `${distro} is not installed yet.`
|
||||
return `Open ${distro} once to finish setup.`
|
||||
})
|
||||
const distroMissingTools = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
if (!probe?.canExecute) return null
|
||||
if (probe.hasBash && probe.hasCurl) return null
|
||||
return probe
|
||||
})
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
|
||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
||||
const installingOpencode = createMemo(() => {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !store.selectedDistro) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
if (!job) return store.adding
|
||||
return store.adding || job.kind !== "probe-opencode"
|
||||
})
|
||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
||||
if (!wslReady()) return "wsl"
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => api.refreshDistros() }
|
||||
}
|
||||
const distro = store.selectedDistro
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
let lastAutoProbe: string | null = null
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const state = current()
|
||||
const distro = defaultInstalledDistro()
|
||||
if (!state || !distro || busy()) return
|
||||
if (store.selectedDistro) return
|
||||
if (existingServerDistros().has(distro.name)) return
|
||||
setStore("selectedDistro", distro.name)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const distros = installableDistros()
|
||||
if (!distros.length) {
|
||||
if (store.installTarget) setStore("installTarget", undefined)
|
||||
return
|
||||
}
|
||||
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
|
||||
setStore("installTarget", distros[0]!.name)
|
||||
})
|
||||
|
||||
const wslMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
|
||||
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
|
||||
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
|
||||
return state.runtime?.error ?? "WSL is required to continue."
|
||||
})
|
||||
|
||||
const distroMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return "Checking distros..."
|
||||
const distro = store.selectedDistro
|
||||
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
|
||||
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
|
||||
if (state.job?.kind === "distros") return "Listing distros..."
|
||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
||||
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
|
||||
if (distro) return `Finishing setup for ${distro}.`
|
||||
return "Pick a distro or install one below."
|
||||
})
|
||||
|
||||
const opencodeMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return "Checking OpenCode..."
|
||||
const distro = store.selectedDistro
|
||||
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
|
||||
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
|
||||
}
|
||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
||||
if (opencodeCheck()?.matchesDesktop === false) {
|
||||
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
|
||||
}
|
||||
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
|
||||
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
|
||||
})
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
await action()
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return
|
||||
void run(() => action(distro))
|
||||
}
|
||||
|
||||
const selectDistro = (name: string) => {
|
||||
setStore("selectedDistro", name)
|
||||
setStore("step", undefined)
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
if (props.onAdded) {
|
||||
await props.onAdded(distro)
|
||||
} else {
|
||||
dialog.close()
|
||||
}
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
} finally {
|
||||
setStore("adding", false)
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createMemo(() => {
|
||||
const active = activeStep()
|
||||
const activeIndex = STEPS.indexOf(active)
|
||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
||||
return STEPS.map((step) => {
|
||||
const index = STEPS.indexOf(step)
|
||||
return {
|
||||
step,
|
||||
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
|
||||
state:
|
||||
active === step
|
||||
? "current"
|
||||
: step === "wsl"
|
||||
? wslReady()
|
||||
? "done"
|
||||
: "warning"
|
||||
: step === "distro"
|
||||
? distroReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning"
|
||||
: opencodeCheck()?.matchesDesktop === false
|
||||
? "warning"
|
||||
: opencodeReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning",
|
||||
locked: index > recommendedIndex,
|
||||
}
|
||||
})
|
||||
})
|
||||
const loadError = createMemo(() => {
|
||||
const error = wslServers.error
|
||||
if (!error) return "Failed to load WSL state."
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
|
||||
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">WSL</div>
|
||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => api.installWsl())}
|
||||
>
|
||||
Install WSL
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
||||
<Show when={current()?.pendingRestart}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
|
||||
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
|
||||
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
|
||||
Relaunch OpenCode
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">Choose a distro</div>
|
||||
<Show when={store.selectedDistro}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{visibleInstalledDistros().length
|
||||
? "All installed distros are already added."
|
||||
: current()?.runtime?.available
|
||||
? "No distros detected yet."
|
||||
: "Checking distros..."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={addableInstalledDistros()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
||||
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
|
||||
onClick={() => selectDistro(item.name)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
||||
<Show when={item.isDefault}>
|
||||
<div class="text-12-regular text-text-weak">Default</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={installableDistros().length > 0}>
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">Install</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={installingDistro()}>
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro() ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Install distro"
|
||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
||||
>
|
||||
<For each={installableDistros()}>
|
||||
{(item) => {
|
||||
const selected = () => store.installTarget === item.name
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected()}
|
||||
disabled={busy()}
|
||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
||||
classList={{
|
||||
"bg-surface-raised-base": selected(),
|
||||
"hover:bg-surface-base": !selected(),
|
||||
}}
|
||||
onClick={() => setStore("installTarget", item.name)}
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
||||
classList={{ "border-text-strong": selected() }}
|
||||
>
|
||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
selectedInstalled()?.version === 1 ||
|
||||
distroUnavailableMessage() ||
|
||||
distroMissingTools()
|
||||
}
|
||||
>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<Show when={selectedInstalled()?.version === 1}>
|
||||
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
|
||||
</Show>
|
||||
<Show when={distroUnavailableMessage()}>
|
||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
||||
</Show>
|
||||
<Show when={distroMissingTools()}>
|
||||
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
||||
>
|
||||
Open terminal
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !store.selectedDistro}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !store.selectedDistro || !distroReady()}
|
||||
onClick={() => setStore("step", "opencode")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "opencode"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">OpenCode</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={store.selectedDistro}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
||||
>
|
||||
<Show when={installingOpencode()}>
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
||||
{(check) => (
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
Version: {check().version ?? "unknown"}
|
||||
<Show when={check().expectedVersion}>
|
||||
{(expected) => <span>{` · desktop ${expected()}`}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
Installed version does not match the desktop app version.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
||||
{store.adding ? "Adding..." : "Add WSL server"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
|
||||
interface ServerRowProps extends ParentProps {
|
||||
conn: ServerConnection.Any
|
||||
status?: ServerHealth
|
||||
version?: string
|
||||
class?: string
|
||||
nameClass?: string
|
||||
versionClass?: string
|
||||
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverName(props.conn))
|
||||
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
|
||||
const version = createMemo(() => props.version ?? props.status?.version)
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
createEffect(() => {
|
||||
name()
|
||||
props.conn.http.url
|
||||
props.status?.version
|
||||
version()
|
||||
queueMicrotask(check)
|
||||
})
|
||||
|
||||
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{serverName(props.conn, true)}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-text-invert-weak">WSL</span>
|
||||
</Show>
|
||||
<Show when={version()}>
|
||||
<span class="text-text-invert-weak">v{version()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
|
||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
||||
WSL
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<Show when={version()}>
|
||||
<span
|
||||
ref={versionRef}
|
||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||
>
|
||||
v{props.status?.version}
|
||||
v{version()}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
@@ -286,7 +286,7 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
|
||||
const sync = useSync()
|
||||
const servers = useServers()
|
||||
const server = useServer()
|
||||
@@ -294,6 +294,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
@@ -374,8 +375,16 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
props.close?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
const activate = () => {
|
||||
if (location.pathname !== "/") {
|
||||
setTimeout(activate, 16)
|
||||
return
|
||||
}
|
||||
setTimeout(() => server.setActive(key), 0)
|
||||
}
|
||||
setTimeout(activate, 0)
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
|
||||
@@ -67,7 +67,7 @@ export function StatusPopover() {
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown} close={() => setShown(false)} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
|
||||
@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => serverSync.child(directory))
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === directory) return current()
|
||||
return serverSync.child(directory)
|
||||
|
||||
@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
store,
|
||||
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { State } from "./types"
|
||||
import type { QueryOptionsApi } from "../server-sync"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
@@ -48,12 +49,15 @@ beforeAll(async () => {
|
||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||
}))
|
||||
mock.module("@tanstack/solid-query", () => ({
|
||||
useQueries: () => [
|
||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||
{ isLoading: false, data: {} },
|
||||
{ isLoading: false, data: [] },
|
||||
{ isLoading: false, data: provider },
|
||||
],
|
||||
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
|
||||
queryGroups.push(options)
|
||||
return [
|
||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||
{ isLoading: false, data: {} },
|
||||
{ isLoading: false, data: [] },
|
||||
{ isLoading: false, data: provider },
|
||||
]
|
||||
},
|
||||
}))
|
||||
|
||||
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||
@@ -73,6 +77,7 @@ describe("createChildStoreManager", () => {
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onMcp() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
@@ -103,6 +108,7 @@ describe("createChildStoreManager", () => {
|
||||
onBootstrap(directory) {
|
||||
bootstraps.push(directory)
|
||||
},
|
||||
onMcp() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
@@ -121,4 +127,45 @@ describe("createChildStoreManager", () => {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("enables MCP only when requested for the directory", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
const offset = queryGroups.length
|
||||
const mcpLoads: string[] = []
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onMcp(directory) {
|
||||
mcpLoads.push(directory)
|
||||
},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||
const queries = queryGroups[offset]
|
||||
if (!queries) throw new Error("queries required")
|
||||
expect(queries().queries[1]?.enabled).toBe(false)
|
||||
|
||||
setStore("status", "complete")
|
||||
manager.child("/project", { bootstrap: false, mcp: true })
|
||||
expect(queries().queries[1]?.enabled).toBe(true)
|
||||
expect(mcpLoads).toEqual(["/project"])
|
||||
|
||||
manager.disableMcp("/project")
|
||||
expect(queries().queries[1]?.enabled).toBe(false)
|
||||
expect(manager.mcp("/project")).toBe(false)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
|
||||
isBooting: (directory: string) => boolean
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
|
||||
const pins = new Map<string, number>()
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
const mcpDirectories = new Set<string>()
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
const markKey = (key: DirectoryKey) => {
|
||||
if (!key) return
|
||||
@@ -92,6 +95,24 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function disposeChild(key: DirectoryKey) {
|
||||
const dispose = disposers.get(key)
|
||||
if (!key || !children[key]) return false
|
||||
vcsCache.delete(key)
|
||||
metaCache.delete(key)
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
mcpDirectories.delete(key)
|
||||
mcpToggles.delete(key)
|
||||
disposers.delete(key)
|
||||
delete children[key]
|
||||
input.onDispose(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function disposeDirectory(directory: DirectoryKey) {
|
||||
const key = directory
|
||||
if (
|
||||
@@ -105,19 +126,13 @@ export function createChildStoreManager(input: {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return disposeChild(key)
|
||||
}
|
||||
|
||||
vcsCache.delete(key)
|
||||
metaCache.delete(key)
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
disposers.delete(key)
|
||||
function disposeAll() {
|
||||
for (const directory of Object.keys(children)) {
|
||||
disposeChild(directoryKey(directory))
|
||||
}
|
||||
delete children[key]
|
||||
input.onDispose(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function runEviction(skip?: string) {
|
||||
@@ -173,11 +188,12 @@ export function createChildStoreManager(input: {
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
|
||||
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
input.queryOptions.path(key),
|
||||
input.queryOptions.mcp(key),
|
||||
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
|
||||
input.queryOptions.lsp(key),
|
||||
input.queryOptions.providers(key),
|
||||
],
|
||||
@@ -236,6 +252,7 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
@@ -274,6 +291,7 @@ export function createChildStoreManager(input: {
|
||||
const key = directoryKey(directory)
|
||||
const childStore = ensureChild(directory)
|
||||
pinForOwner(key)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
@@ -284,6 +302,7 @@ export function createChildStoreManager(input: {
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const key = directoryKey(directory)
|
||||
const childStore = ensureChild(directory)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
@@ -291,6 +310,19 @@ export function createChildStoreManager(input: {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||
if (mcpDirectories.has(key)) return
|
||||
mcpDirectories.add(key)
|
||||
mcpToggles.get(key)?.(true)
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
function disableMcp(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!mcpDirectories.delete(key)) return
|
||||
mcpToggles.get(key)?.(false)
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const key = directoryKey(directory)
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
@@ -330,7 +362,10 @@ export function createChildStoreManager(input: {
|
||||
pin,
|
||||
unpin,
|
||||
pinned,
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
disposeDirectory,
|
||||
disposeAll,
|
||||
runEviction,
|
||||
vcsCache,
|
||||
metaCache,
|
||||
|
||||
@@ -98,6 +98,7 @@ export type IconCache = {
|
||||
|
||||
export type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
mcp?: boolean
|
||||
}
|
||||
|
||||
export type DirState = {
|
||||
|
||||
@@ -20,6 +20,88 @@ export type FatalRendererErrorLog = {
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
export type WslRuntimeCheck = {
|
||||
available: boolean
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslInstalledDistro = {
|
||||
name: string
|
||||
version: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
export type WslOnlineDistro = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
export type WslDistroProbe = {
|
||||
name: string
|
||||
canExecute: boolean
|
||||
hasBash: boolean
|
||||
hasCurl: boolean
|
||||
error: string | null
|
||||
}
|
||||
export type WslOpencodeCheck = {
|
||||
distro: string
|
||||
resolvedPath: string | null
|
||||
version: string | null
|
||||
expectedVersion: string | null
|
||||
matchesDesktop: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslServerConfig = {
|
||||
id: string
|
||||
distro: string
|
||||
}
|
||||
|
||||
export type WslServerRuntime =
|
||||
| { kind: "starting" }
|
||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
||||
| { kind: "failed"; message: string }
|
||||
| { kind: "stopped" }
|
||||
|
||||
export type WslServerItem = {
|
||||
config: WslServerConfig
|
||||
runtime: WslServerRuntime
|
||||
}
|
||||
|
||||
export type WslJob =
|
||||
| { kind: "runtime"; startedAt: number }
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
runtime: WslRuntimeCheck | null
|
||||
installed: WslInstalledDistro[]
|
||||
online: WslOnlineDistro[]
|
||||
distroProbes: Record<string, WslDistroProbe>
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
||||
pendingRestart: boolean
|
||||
servers: WslServerItem[]
|
||||
job: WslJob | null
|
||||
}
|
||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
||||
|
||||
export type WslServersPlatform = {
|
||||
getState(): Promise<WslServersState>
|
||||
subscribe(cb: (event: WslServersEvent) => void): () => void
|
||||
probeRuntime(): Promise<void>
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(name: string): Promise<void>
|
||||
probeDistro(name: string): Promise<void>
|
||||
probeOpencode(name: string): Promise<void>
|
||||
installOpencode(name: string): Promise<void>
|
||||
openTerminal(name: string): Promise<void>
|
||||
addServer(distro: string): Promise<WslServerConfig>
|
||||
removeServer(id: string): Promise<void>
|
||||
startServer(id: string): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: PlatformName
|
||||
@@ -75,11 +157,8 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
/** Set the configured WSL integration (desktop only) */
|
||||
setWslEnabled?(config: boolean): Promise<void> | void
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
|
||||
@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -205,6 +206,15 @@ export function createServerSyncContext() {
|
||||
onBootstrap: (directory) => {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
@@ -311,6 +321,7 @@ export function createServerSyncContext() {
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
mcp: children.mcp(key),
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
@@ -382,11 +393,7 @@ export function createServerSyncContext() {
|
||||
onCleanup(() => {
|
||||
queue.dispose()
|
||||
})
|
||||
onCleanup(() => {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
children.disposeDirectory(directoryKey(directory))
|
||||
}
|
||||
})
|
||||
onCleanup(children.disposeAll)
|
||||
|
||||
onMount(() => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -437,6 +444,7 @@ export function createServerSyncContext() {
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
queryOptions: queryOptionsApi,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
@@ -454,7 +462,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
||||
|
||||
return {
|
||||
...sync,
|
||||
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
|
||||
createDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, sync),
|
||||
(dir) => sync.disableMcp(dir),
|
||||
directoryKey,
|
||||
),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
@@ -151,6 +151,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (state.active !== input) setState("active", input)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.__OPENCODE__ ??= {}
|
||||
window.__OPENCODE__.activeServer = state.active
|
||||
})
|
||||
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
if (!url_) return
|
||||
@@ -187,7 +193,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
)
|
||||
const isLocal = createMemo(() => {
|
||||
const c = current()
|
||||
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import type { WslServersPlatform, WslServersState } from "./platform"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
const wslServersQueryKey = ["platform", "wslServers"] as const
|
||||
|
||||
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
|
||||
name: "WslServers",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery(() => {
|
||||
const api = platform.wslServers
|
||||
return queryOptions<WslServersState>({
|
||||
queryKey: wslServersQueryKey,
|
||||
queryFn: api ? () => api.getState() : skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
const off = api.subscribe((event) => {
|
||||
queryClient.setQueryData(wslServersQueryKey, event.state)
|
||||
})
|
||||
onCleanup(off)
|
||||
})
|
||||
|
||||
return query
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,24 @@ export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { useWslServers } from "./context/wsl-servers"
|
||||
export {
|
||||
type DisplayBackend,
|
||||
type FatalRendererErrorLog,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
type WslJob,
|
||||
type WslOnlineDistro,
|
||||
type WslOpencodeCheck,
|
||||
type WslRuntimeCheck,
|
||||
type WslServerConfig,
|
||||
type WslServerItem,
|
||||
type WslServerRuntime,
|
||||
type WslServersEvent,
|
||||
type WslServersPlatform,
|
||||
type WslServersState,
|
||||
} from "./context/platform"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
||||
@@ -701,6 +701,7 @@ function LegacyHome() {
|
||||
if (healthy === false) return "bg-icon-critical-base"
|
||||
return "bg-border-weak-base"
|
||||
})
|
||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
||||
|
||||
function openProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
@@ -719,7 +720,7 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
@@ -740,7 +741,7 @@ function LegacyHome() {
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
@@ -1221,7 +1222,7 @@ export default function Layout(props: ParentProps) {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1474,7 +1475,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createRefCountMap } from "./refcount"
|
||||
import { pathKey } from "./path-key"
|
||||
|
||||
describe("createRefCountMap", () => {
|
||||
test("removes an item after its last owner is disposed", () => {
|
||||
const removed: string[] = []
|
||||
const map = createRefCountMap(
|
||||
(key) => key,
|
||||
(key) => removed.push(key),
|
||||
)
|
||||
const first = createRoot((dispose) => {
|
||||
map("/project")
|
||||
return dispose
|
||||
})
|
||||
const second = createRoot((dispose) => {
|
||||
map("/project")
|
||||
return dispose
|
||||
})
|
||||
|
||||
first()
|
||||
expect(removed).toEqual([])
|
||||
second()
|
||||
expect(removed).toEqual(["/project"])
|
||||
})
|
||||
|
||||
test("keeps equivalent path consumers until the last owner is disposed", () => {
|
||||
const removed: string[] = []
|
||||
const map = createRefCountMap(
|
||||
(key) => key,
|
||||
(key) => removed.push(key),
|
||||
pathKey,
|
||||
)
|
||||
const first = createRoot((dispose) => {
|
||||
map("C:\\repo")
|
||||
return dispose
|
||||
})
|
||||
const second = createRoot((dispose) => {
|
||||
map("C:/repo/")
|
||||
return dispose
|
||||
})
|
||||
|
||||
first()
|
||||
expect(removed).toEqual([])
|
||||
second()
|
||||
expect(removed).toEqual(["C:/repo"])
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,32 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export function createRefCountMap<T>(create: (key: string) => T) {
|
||||
export function createRefCountMap<T>(
|
||||
create: (key: string) => T,
|
||||
remove?: (key: string) => void,
|
||||
identity: (key: string) => string = (key) => key,
|
||||
) {
|
||||
const items = new Map<string, T>()
|
||||
const refCounts = new Map<string, number>()
|
||||
|
||||
return (key: string) => {
|
||||
const id = identity(key)
|
||||
onCleanup(() => {
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
|
||||
if (refCounts.get(key) === 0) {
|
||||
items.delete(key)
|
||||
refCounts.delete(key)
|
||||
refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
|
||||
if (refCounts.get(id) === 0) {
|
||||
remove?.(id)
|
||||
items.delete(id)
|
||||
refCounts.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
const cached = items.get(key)
|
||||
const cached = items.get(id)
|
||||
if (cached) {
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
|
||||
return cached
|
||||
}
|
||||
const item = create(key)
|
||||
items.set(key, item)
|
||||
refCounts.set(key, 1)
|
||||
items.set(id, item)
|
||||
refCounts.set(id, 1)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||
import { createRoot, onCleanup, type JSXElement } from "solid-js"
|
||||
import type { JSXElement } from "solid-js"
|
||||
|
||||
type DragEvent = { draggable?: { id?: unknown } }
|
||||
|
||||
@@ -27,20 +27,16 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
|
||||
if (!context) return null
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer = createTransformer(transformerId, axis)
|
||||
const dispose = createRoot((dispose) => {
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return dispose
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
onCleanup(dispose)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Modal } from "~/component/modal"
|
||||
import { IconCheck, IconCopy } from "~/component/icon"
|
||||
@@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
|
||||
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
import "./go-referral.css"
|
||||
|
||||
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
|
||||
@@ -25,7 +27,21 @@ const emptyUsagePreview = {
|
||||
|
||||
export const queryGoReferral = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(() => Referral.summary(), workspaceID)
|
||||
return withActor(async () => {
|
||||
const event = getRequestEvent()
|
||||
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
|
||||
if (referralCode) {
|
||||
await Referral.createFromAccount({
|
||||
accountID: Actor.account(),
|
||||
referralCode,
|
||||
}).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
event?.response.headers.append("set-cookie", clearReferralCookie())
|
||||
}
|
||||
|
||||
return Referral.summary()
|
||||
}, workspaceID)
|
||||
}, "go.referral.get")
|
||||
|
||||
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
|
||||
@@ -65,6 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
|
||||
|
||||
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
|
||||
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
|
||||
if (reward.status === "pending" && reward.source === "inviter")
|
||||
return "workspace.referral.reward.source.pendingInviter" as const
|
||||
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
|
||||
return "workspace.referral.reward.action.view" as const
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Referral } from "@opencode-ai/console-core/referral.js"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest, route } from "~/lib/language"
|
||||
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
@@ -19,7 +17,6 @@ export async function GET(input: APIEvent) {
|
||||
if (result.err) throw new Error(result.err.message)
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
@@ -35,15 +32,8 @@ export async function GET(input: APIEvent) {
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
if (decoded.subject.properties.newAccount && referralCode) {
|
||||
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
|
||||
console.error("Referral create failed", error)
|
||||
})
|
||||
}
|
||||
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
|
||||
const response = redirect(route(locale, next))
|
||||
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
|
||||
return response
|
||||
return redirect(route(locale, next))
|
||||
} catch (e: any) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -64,11 +64,12 @@ function LimitsGraph(props: { href: string }) {
|
||||
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
|
||||
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode-ai/console-core/key.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||
import styles from "./key-section.module.css"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -124,7 +123,6 @@ export function KeySection() {
|
||||
<th>{i18n.t("workspace.keys.table.name")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.key")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
|
||||
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -156,9 +154,6 @@ export function KeySection() {
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="key-user-email">{key.email}</td>
|
||||
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
|
||||
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<form action={removeKey} method="post">
|
||||
<input type="hidden" name="id" value={key.id} />
|
||||
|
||||
@@ -506,7 +506,6 @@ export async function handler(
|
||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||
let topPriority = Infinity
|
||||
const providers = allProviders
|
||||
.filter((provider) => !provider.disabled)
|
||||
.filter((provider) => provider.weight !== 0)
|
||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||
.filter((provider) => {
|
||||
@@ -990,10 +989,6 @@ export async function handler(
|
||||
return undefined
|
||||
})(),
|
||||
}),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
...(() => {
|
||||
if (billingSource === "subscription") {
|
||||
const plan = authInfo.billing.subscription!.plan
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
port: 3001,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod"
|
||||
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
|
||||
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
|
||||
import { Actor } from "./actor"
|
||||
import { Identifier } from "./identifier"
|
||||
import { LiteTable } from "./schema/billing.sql"
|
||||
import { LiteTable, PaymentTable } from "./schema/billing.sql"
|
||||
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
@@ -318,6 +318,26 @@ export namespace Referral {
|
||||
.then((rows) => rows[0])
|
||||
if (selfReferral) throw new Error("Self-referral is not allowed")
|
||||
|
||||
const workspaceIDs = await tx
|
||||
.select({ workspaceID: UserTable.workspaceID })
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows.map((row) => row.workspaceID))
|
||||
if (workspaceIDs.length === 0) return
|
||||
|
||||
const litePayment = await tx
|
||||
.select({ id: PaymentTable.id })
|
||||
.from(PaymentTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(PaymentTable.workspaceID, workspaceIDs),
|
||||
isNull(PaymentTable.timeDeleted),
|
||||
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
if (litePayment) return
|
||||
|
||||
const referralID = Identifier.create("referral")
|
||||
await tx.insert(ReferralTable).ignore().values({
|
||||
workspaceID: code.workspaceID,
|
||||
@@ -355,7 +375,7 @@ export namespace Referral {
|
||||
.from(ReferralTable)
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.then((rows) => rows[0])
|
||||
if (!referral) throw new Error("Referral not found")
|
||||
if (!referral) return
|
||||
|
||||
const result = await tx
|
||||
.insert(ReferralRewardTable)
|
||||
@@ -373,7 +393,7 @@ export namespace Referral {
|
||||
},
|
||||
])
|
||||
|
||||
if (result.rowsAffected === 0) throw new Error("Referral already completed")
|
||||
if (result.rowsAffected === 0) return
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
export * as AuthWellKnown from "./auth-well-known"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Global } from "./global"
|
||||
import { Substitution } from "./substitution"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("AuthWellKnown.Entry")({
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AuthWellKnown.FileWriteError", {
|
||||
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export class RemoteConfigError extends Schema.TaggedErrorClass<RemoteConfigError>()("AuthWellKnown.RemoteConfigError", {
|
||||
url: Schema.String,
|
||||
status: Schema.Number.pipe(Schema.optional),
|
||||
cause: Schema.Defect.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type Error = FileWriteError | RemoteConfigError
|
||||
|
||||
const RemoteConfig = Schema.Struct({
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export class Metadata extends Schema.Class<Metadata>("AuthWellKnown.Metadata")({
|
||||
auth: Schema.Struct({
|
||||
command: Schema.Array(Schema.String),
|
||||
env: Schema.String,
|
||||
}).pipe(Schema.optional),
|
||||
config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
remote_config: RemoteConfig.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type ConfigDocument = {
|
||||
url: string
|
||||
source: string
|
||||
dir: string
|
||||
content: unknown
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => Effect.Effect<Record<string, Entry>, Error>
|
||||
readonly get: (url: string) => Effect.Effect<Entry | undefined, Error>
|
||||
readonly set: (url: string, entry: Entry) => Effect.Effect<void, Error>
|
||||
readonly remove: (url: string) => Effect.Effect<void, Error>
|
||||
readonly metadata: (url: string) => Effect.Effect<Metadata, Error>
|
||||
readonly configs: () => Effect.Effect<ConfigDocument[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AuthWellKnown") {}
|
||||
const decodeMetadata = Schema.decodeUnknownEffect(Metadata)
|
||||
const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig)
|
||||
|
||||
function loadLegacyAuth(input: {
|
||||
fsys: AppFileSystem.Interface
|
||||
dataDir: string
|
||||
write: (data: Record<string, Entry>) => Effect.Effect<void, Error>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeLegacyCredential = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("wellknown"),
|
||||
key: Schema.String,
|
||||
token: Schema.String,
|
||||
}),
|
||||
)
|
||||
const legacy = Object.fromEntries(
|
||||
Object.entries(
|
||||
Option.getOrElse(
|
||||
decodeLegacy(
|
||||
yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)),
|
||||
),
|
||||
() => ({}),
|
||||
),
|
||||
).flatMap(([url, value]) => {
|
||||
const decoded = Option.getOrUndefined(decodeLegacyCredential(value))
|
||||
return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : []
|
||||
}),
|
||||
)
|
||||
if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore)
|
||||
return legacy
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const substitution = yield* Substitution.Service
|
||||
const file = path.join(global.data, "well-known.json")
|
||||
const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
||||
const normalizeUrl = (url: string) => url.replace(/\/+$/, "")
|
||||
|
||||
const write = (operation: "migrate" | "write", data: Record<string, Entry>) =>
|
||||
fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause })))
|
||||
|
||||
const load: () => Effect.Effect<Record<string, Entry>> = Effect.fnUntraced(function* () {
|
||||
const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
|
||||
if (current && typeof current === "object")
|
||||
return Option.getOrElse(decodeEntries(current), () => ({}) as Record<string, Entry>)
|
||||
return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) })
|
||||
})
|
||||
|
||||
const state = SynchronizedRef.makeUnsafe<Record<string, Entry>>(yield* load())
|
||||
|
||||
const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) {
|
||||
const normalized = normalizeUrl(url)
|
||||
const source = `${normalized}/.well-known/opencode`
|
||||
const response = yield* HttpClientRequest.get(source).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* new RemoteConfigError({ url: source, status: response.status })
|
||||
}
|
||||
const metadata = yield* response.json.pipe(
|
||||
Effect.flatMap(decodeMetadata),
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })),
|
||||
)
|
||||
return { url: normalized, source, dir: path.dirname(source), metadata }
|
||||
})
|
||||
|
||||
const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record<string, string> }) {
|
||||
const response = yield* HttpClientRequest.get(input.url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request,
|
||||
http.execute,
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })),
|
||||
)
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return yield* new RemoteConfigError({ url: input.url, status: response.status })
|
||||
}
|
||||
return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
all: Effect.fn("AuthWellKnown.all")(function* () {
|
||||
return yield* SynchronizedRef.get(state)
|
||||
}),
|
||||
|
||||
get: Effect.fn("AuthWellKnown.get")(function* (url) {
|
||||
return (yield* SynchronizedRef.get(state))[normalizeUrl(url)]
|
||||
}),
|
||||
|
||||
set: Effect.fn("AuthWellKnown.set")(function* (url, entry) {
|
||||
yield* SynchronizedRef.updateEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (data) {
|
||||
const next = { ...data, [normalizeUrl(url)]: entry }
|
||||
yield* write("write", next)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
remove: Effect.fn("AuthWellKnown.remove")(function* (url) {
|
||||
yield* SynchronizedRef.updateEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (data) {
|
||||
const next = { ...data }
|
||||
delete next[url]
|
||||
delete next[normalizeUrl(url)]
|
||||
yield* write("write", next)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) {
|
||||
return (yield* metadata(url)).metadata
|
||||
}),
|
||||
|
||||
configs: Effect.fn("AuthWellKnown.configs")(function* () {
|
||||
const documents = yield* Effect.all(
|
||||
Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) =>
|
||||
Effect.gen(function* () {
|
||||
const configs: ConfigDocument[] = []
|
||||
const response = yield* metadata(url)
|
||||
const env = { [entry.key]: entry.token }
|
||||
if (response.metadata.config) {
|
||||
configs.push({
|
||||
url: response.url,
|
||||
source: response.source,
|
||||
dir: response.dir,
|
||||
content: response.metadata.config,
|
||||
})
|
||||
}
|
||||
if (response.metadata.remote_config) {
|
||||
const remoteConfig = yield* substitution
|
||||
.substitute({
|
||||
text: JSON.stringify(response.metadata.remote_config),
|
||||
type: "virtual",
|
||||
dir: response.url,
|
||||
source: response.source,
|
||||
env,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((text) =>
|
||||
Effect.try({
|
||||
try: () => JSON.parse(text) as unknown,
|
||||
catch: (cause) => new RemoteConfigError({ url: response.source, cause }),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(decodeRemoteConfig),
|
||||
Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })),
|
||||
)
|
||||
configs.push({
|
||||
url: remoteConfig.url,
|
||||
source: remoteConfig.url,
|
||||
dir: path.dirname(remoteConfig.url),
|
||||
content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }),
|
||||
})
|
||||
}
|
||||
return configs
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return documents.flat()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
export * as Substitution from "./substitution"
|
||||
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
|
||||
type Source =
|
||||
| {
|
||||
type: "path"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
type: "virtual"
|
||||
source: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
export type Input = Source & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export class FileReferenceError extends Schema.TaggedErrorClass<FileReferenceError>()("Substitution.FileReferenceError", {
|
||||
source: Schema.String,
|
||||
token: Schema.String,
|
||||
resolved: Schema.String,
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export type Error = FileReferenceError
|
||||
|
||||
export interface Interface {
|
||||
readonly substitute: (input: Input) => Effect.Effect<string, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Substitution") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
return Service.of({
|
||||
substitute: Effect.fn("Substitution.substitute")(function* (input) {
|
||||
const missing = input.missing ?? "error"
|
||||
const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return input.env?.[varName] ?? process.env[varName] ?? ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
|
||||
const configSource = input.type === "path" ? input.path : input.source
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
const reference = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference
|
||||
const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath)
|
||||
const content = yield* fs.readFileString(resolved).pipe(
|
||||
Effect.catch((cause) => {
|
||||
if (missing === "empty") return Effect.succeed("")
|
||||
return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause }))
|
||||
}),
|
||||
)
|
||||
|
||||
out += JSON.stringify(content.trim()).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const unexpectedHttpClient = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.url}`))
|
||||
|
||||
const withAuthWellKnown = <A, E, R>(
|
||||
dir: string,
|
||||
effect: Effect.Effect<A, E, R | AuthWellKnown.Service>,
|
||||
client = unexpectedHttpClient,
|
||||
) =>
|
||||
effect.pipe(
|
||||
Effect.provide(AuthWellKnown.layer),
|
||||
Effect.provide(AppFileSystem.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ data: dir })),
|
||||
Effect.provide(Layer.succeed(HttpClient.HttpClient, client)),
|
||||
Effect.provide(Substitution.defaultLayer),
|
||||
)
|
||||
|
||||
const wellKnownConfigClient = HttpClient.make((request) => {
|
||||
if (request.url === "https://example.com/.well-known/opencode") {
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
config: { instructions: ["local"] },
|
||||
remote_config: {
|
||||
url: "https://remote.example.com/config",
|
||||
headers: {
|
||||
authorization: "Bearer {env:TEST_TOKEN}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (request.url === "https://remote.example.com/config") {
|
||||
expect(request.headers.authorization).toBe("Bearer secret")
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ model: "remote/model" })))
|
||||
}
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })))
|
||||
})
|
||||
|
||||
describe("AuthWellKnown", () => {
|
||||
it.live("stores well-known credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" }))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates legacy well-known auth records", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "auth.json"),
|
||||
JSON.stringify({
|
||||
"https://example.com": {
|
||||
type: "wellknown",
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const entry = yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
return yield* auth.get("https://example.com/")
|
||||
}),
|
||||
)
|
||||
|
||||
expect(entry).toEqual({
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads config documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "well-known.json"),
|
||||
JSON.stringify({
|
||||
"https://example.com": {
|
||||
key: "TEST_TOKEN",
|
||||
token: "secret",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = yield* withAuthWellKnown(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* AuthWellKnown.Service
|
||||
return yield* auth.configs()
|
||||
}),
|
||||
wellKnownConfigClient,
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
url: "https://example.com",
|
||||
source: "https://example.com/.well-known/opencode",
|
||||
dir: "https://example.com/.well-known",
|
||||
content: { instructions: ["local"] },
|
||||
},
|
||||
{
|
||||
url: "https://remote.example.com/config",
|
||||
source: "https://remote.example.com/config",
|
||||
dir: "https://remote.example.com",
|
||||
content: { model: "remote/model" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execFile, execFileSync } from "node:child_process"
|
||||
import { execFile } from "node:child_process"
|
||||
import { access, readFile, readdir } from "node:fs/promises"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
import { resolveWslHome, runWslInDistro } from "./wsl"
|
||||
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
|
||||
@@ -21,20 +22,44 @@ export function resolveAppPath(appName: string) {
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
|
||||
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` UNC paths that
|
||||
// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
|
||||
// backslashes get shell-collapsed when passed through `wsl.exe`, turning
|
||||
// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
|
||||
// which is a valid-looking path that wedges opencode on DrvFs stat calls.
|
||||
function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
|
||||
// Normalise separators; both `\\` and `//` prefixes mean UNC.
|
||||
const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
|
||||
const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
|
||||
if (!match) return null
|
||||
const distro = match[2]
|
||||
const subpath = match[3] ?? ""
|
||||
return { distro, subpath }
|
||||
}
|
||||
|
||||
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
|
||||
if (process.platform !== "win32") return path
|
||||
|
||||
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` in
|
||||
// the target distro. Do the conversion in-process rather than shelling out
|
||||
// to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
|
||||
// joiner. If the requested distro differs from the UNC distro, we still
|
||||
// translate literally — callers are responsible for only picking paths
|
||||
// inside the active distro.
|
||||
if (mode === "linux") {
|
||||
const unc = parseWslUncPath(path)
|
||||
if (unc) return `/${unc.subpath}`
|
||||
}
|
||||
|
||||
const flag = mode === "windows" ? "-w" : "-u"
|
||||
try {
|
||||
if (path.startsWith("~")) {
|
||||
const suffix = path.slice(1)
|
||||
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
|
||||
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
|
||||
return output.toString().trim()
|
||||
const resolved = path.startsWith("~") ? `${await resolveWslHome(distro)}${path.slice(1)}` : path
|
||||
const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
|
||||
const output = await runWslInDistro(["wslpath", flag, input], distro)
|
||||
if (output.code !== 0) {
|
||||
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
|
||||
}
|
||||
|
||||
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
|
||||
return output.toString().trim()
|
||||
return output.stdout.trim()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
|
||||
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const WSL_ENABLED_KEY = "wslEnabled"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
@@ -9,9 +9,10 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
@@ -20,13 +21,13 @@ import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
getWslConfig,
|
||||
preferAppEnv,
|
||||
setDefaultServerUrl,
|
||||
setWslConfig,
|
||||
spawnLocalServer,
|
||||
spawnWslSidecar,
|
||||
type SidecarListener,
|
||||
} from "./server"
|
||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
@@ -35,9 +36,8 @@ import {
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { createWslServersController } from "./wsl-servers"
|
||||
import { migrate } from "./migrate"
|
||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
@@ -145,6 +145,30 @@ const main = Effect.gen(function* () {
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
const wslServers = createWslServersController(
|
||||
app.getVersion(),
|
||||
async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
{
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
)
|
||||
const stopSidecars = async () => {
|
||||
await killSidecar()
|
||||
wslServers.stopAll()
|
||||
}
|
||||
const relaunch = () => {
|
||||
void stopSidecars().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
@@ -190,11 +214,11 @@ const main = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void killSidecar()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
void killSidecar()
|
||||
void stopSidecars()
|
||||
})
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
@@ -214,7 +238,7 @@ const main = Effect.gen(function* () {
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
void stopSidecars().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +247,7 @@ const main = Effect.gen(function* () {
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* (sendStep) {
|
||||
sendStep(initStep)
|
||||
@@ -239,22 +264,33 @@ const main = Effect.gen(function* () {
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
),
|
||||
getWslServersState: () => wslServers.getState(),
|
||||
onWslServersEvent: (listener) => wslServers.subscribe(listener),
|
||||
wslServersProbeRuntime: () => wslServers.probeRuntime(),
|
||||
wslServersRefreshDistros: () => wslServers.refreshDistros(),
|
||||
wslServersInstallWsl: () => wslServers.installWsl(),
|
||||
wslServersInstallDistro: (name) => wslServers.installDistro(name),
|
||||
wslServersProbeDistro: (name) => wslServers.probeDistro(name),
|
||||
wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
|
||||
wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
|
||||
wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
|
||||
wslServersAddServer: (distro) => wslServers.addServer(distro),
|
||||
wslServersRemoveServer: (id) => wslServers.removeServer(id),
|
||||
wslServersStartServer: (id) => wslServers.startServer(id),
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
wslPath: async (path, mode, distro) => wslPath(path, mode, distro),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(killSidecar),
|
||||
installUpdate: async () => installUpdate(stopSidecars),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
@@ -341,6 +377,10 @@ const main = Effect.gen(function* () {
|
||||
password,
|
||||
})
|
||||
|
||||
void wslServers
|
||||
.initialize({ defaultServer: getDefaultServerUrl() })
|
||||
.catch((error) => logger.error("wsl server initialization failed", error))
|
||||
|
||||
yield* Effect.promise(() => health.wait).pipe(
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.catch((e) =>
|
||||
@@ -379,7 +419,7 @@ const main = Effect.gen(function* () {
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true, killSidecar)
|
||||
void checkForUpdates(true, stopSidecars)
|
||||
},
|
||||
relaunch: () => {
|
||||
void killSidecar().finally(() => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import type {
|
||||
SqliteMigrationProgress,
|
||||
TitlebarTheme,
|
||||
WindowConfig,
|
||||
WslConfig,
|
||||
WslServerConfig,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { getStore } from "./store"
|
||||
@@ -23,18 +25,30 @@ const pickerFilters = (ext?: string[]) => {
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => Promise<void> | void
|
||||
relaunch: () => void
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWslServersState: () => Promise<WslServersState> | WslServersState
|
||||
onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
|
||||
wslServersProbeRuntime: () => Promise<void> | void
|
||||
wslServersRefreshDistros: () => Promise<void> | void
|
||||
wslServersInstallWsl: () => Promise<void> | void
|
||||
wslServersInstallDistro: (name: string) => Promise<void> | void
|
||||
wslServersProbeDistro: (name: string) => Promise<void> | void
|
||||
wslServersProbeOpencode: (name: string) => Promise<void> | void
|
||||
wslServersInstallOpencode: (name: string) => Promise<void> | void
|
||||
wslServersOpenTerminal: (name: string) => Promise<void> | void
|
||||
wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
|
||||
wslServersRemoveServer: (id: string) => Promise<void> | void
|
||||
wslServersStartServer: (id: string) => Promise<void> | void
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
getWslConfig: () => Promise<WslConfig>
|
||||
setWslConfig: (config: WslConfig) => Promise<void> | void
|
||||
getDisplayBackend: () => Promise<string | null>
|
||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
loadingWindowComplete: () => void
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||
@@ -46,27 +60,89 @@ type Deps = {
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const requireString = (name: string, value: unknown) => {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
const wslSubscriptions = new Map<number, () => void>()
|
||||
const unsubscribeWsl = (id: number) => {
|
||||
const off = wslSubscriptions.get(id)
|
||||
if (!off) return
|
||||
off()
|
||||
wslSubscriptions.delete(id)
|
||||
}
|
||||
|
||||
app.once("will-quit", () => {
|
||||
for (const off of wslSubscriptions.values()) off()
|
||||
wslSubscriptions.clear()
|
||||
})
|
||||
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||
return deps.awaitInitialization(send)
|
||||
})
|
||||
ipcMain.handle("wsl-servers-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
if (wslSubscriptions.has(id)) return
|
||||
wslSubscriptions.set(
|
||||
id,
|
||||
deps.onWslServersEvent((payload) => {
|
||||
if (event.sender.isDestroyed()) {
|
||||
unsubscribeWsl(id)
|
||||
return
|
||||
}
|
||||
event.sender.send("wsl-servers-event", payload)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => unsubscribeWsl(id))
|
||||
})
|
||||
ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribeWsl(event.sender.id))
|
||||
ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
|
||||
ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
|
||||
ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
|
||||
ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
|
||||
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
deps.wslServersInstallDistro(requireString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
deps.wslServersProbeDistro(requireString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
deps.wslServersProbeOpencode(requireString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
deps.wslServersInstallOpencode(requireString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
|
||||
deps.wslServersOpenTerminal(requireString("distro", name)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) =>
|
||||
deps.wslServersAddServer(requireString("distro", distro)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
deps.wslServersRemoveServer(requireString("server id", id)),
|
||||
)
|
||||
ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) =>
|
||||
deps.wslServersStartServer(requireString("server id", id)),
|
||||
)
|
||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||
deps.setDefaultServerUrl(url),
|
||||
)
|
||||
ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
|
||||
ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
|
||||
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||
deps.setDisplayBackend(backend),
|
||||
)
|
||||
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
|
||||
deps.wslPath(path, mode),
|
||||
ipcMain.handle(
|
||||
"wsl-path",
|
||||
(_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
|
||||
deps.wslPath(path, mode, distro),
|
||||
)
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
|
||||
@@ -191,8 +267,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
|
||||
ipcMain.on("relaunch", () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
deps.relaunch()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:net"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
import { DEFAULT_SERVER_URL_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./wsl"
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
@@ -46,15 +48,6 @@ export function setDefaultServerUrl(url: string | null) {
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function getWslConfig(): WslConfig {
|
||||
const value = getStore().get(WSL_ENABLED_KEY)
|
||||
return { enabled: typeof value === "boolean" ? value : false }
|
||||
}
|
||||
|
||||
export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(process.env, {
|
||||
@@ -201,6 +194,133 @@ export async function spawnLocalServer(
|
||||
}
|
||||
}
|
||||
|
||||
export type WslSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function spawnWslSidecar(
|
||||
distro: string,
|
||||
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
|
||||
): Promise<WslSidecar> {
|
||||
// Do not pass --user here: the sidecar should inherit the distro's
|
||||
// default user so config, auth, git, ssh, and file ownership match the
|
||||
// user's normal WSL environment. If that default user is root, WSL will
|
||||
// choose root itself.
|
||||
const opencode = await resolveWslOpencode(distro)
|
||||
if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
|
||||
|
||||
const port = await allocatePort()
|
||||
const password = randomUUID()
|
||||
const username = "opencode"
|
||||
const logLevel = app.isPackaged ? "WARN" : "INFO"
|
||||
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
// wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
|
||||
// to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
|
||||
// instance middleware falls back to `process.cwd()` when a request
|
||||
// arrives without a `directory=` query or `x-opencode-directory` header
|
||||
// (see opencode server.ts InstanceMiddleware), and then calls
|
||||
// `realpathSync(process.cwd())` synchronously on the main thread. A
|
||||
// statx against a 9p path can wedge the whole event loop in kernel
|
||||
// uninterruptible sleep, freezing the accept loop. Move cwd to the
|
||||
// user's native Linux home so the fallback can't land on DrvFs.
|
||||
'cd "$HOME" || cd /',
|
||||
// wsl.exe by default splices the Windows %PATH% into the distro's $PATH
|
||||
// via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
|
||||
// the sidecar spawns — PTY login shells, plugin helpers, etc. — then
|
||||
// inherits it, which means `which pwsh.exe` resolves to the Windows
|
||||
// PowerShell binary and bash-l profiles that end with
|
||||
// eval "$(oh-my-posh init bash)" (or similar)
|
||||
// silently run Windows pwsh for prompt rendering, whose banner
|
||||
// ("Loading personal and system profiles took Xms.") then shows up in
|
||||
// opencode's terminal pane. We want a clean, Linux-only environment in
|
||||
// the sidecar, so filter every /mnt/* segment out of PATH and clear
|
||||
// WSLENV so no further Windows vars leak in. Users who really need
|
||||
// Windows binaries in the sidecar can invoke them by absolute path.
|
||||
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
|
||||
"export PATH",
|
||||
"export WSLENV=",
|
||||
// WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
|
||||
// watcher/discovery features off there because DrvFs/9p stalls can wedge
|
||||
// the server process after it starts listening.
|
||||
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
|
||||
"export OPENCODE_CLIENT=desktop",
|
||||
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
|
||||
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
|
||||
'export XDG_STATE_HOME="$HOME/.local/state"',
|
||||
`exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
|
||||
].join("\n")
|
||||
|
||||
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
})
|
||||
child.stdin.end(script)
|
||||
|
||||
let settled = false
|
||||
const recentOutput: string[] = []
|
||||
const emit = (line: WslCommandLine) => {
|
||||
if (settled || !line.text.trim()) return
|
||||
recentOutput.push(`[${line.stream}] ${line.text}`)
|
||||
if (recentOutput.length > 12) recentOutput.shift()
|
||||
opts.onLine?.(line)
|
||||
}
|
||||
|
||||
forwardLines(child.stdout, "stdout", emit)
|
||||
forwardLines(child.stderr, "stderr", emit)
|
||||
|
||||
const exit = new Promise<never>((_, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("exit", (code, signal) => {
|
||||
reject(new Error(startupFailure(code, signal, recentOutput)))
|
||||
})
|
||||
})
|
||||
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const healthPromise = (async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
}
|
||||
})()
|
||||
|
||||
const timeoutMs = opts.healthTimeoutMs ?? 30_000
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(
|
||||
() => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
)
|
||||
void healthPromise.finally(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([healthPromise, exit, timeout])
|
||||
} catch (error) {
|
||||
child.kill()
|
||||
throw error
|
||||
} finally {
|
||||
settled = true
|
||||
}
|
||||
|
||||
return {
|
||||
listener: {
|
||||
stop() {
|
||||
child.kill()
|
||||
},
|
||||
onExit(cb) {
|
||||
child.once("exit", cb)
|
||||
},
|
||||
},
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
let healthUrl: URL
|
||||
try {
|
||||
@@ -227,6 +347,46 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
}
|
||||
|
||||
function allocatePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
reject(new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function forwardLines(
|
||||
stream: NodeJS.ReadableStream,
|
||||
source: WslCommandLine["stream"],
|
||||
onLine: (line: WslCommandLine) => void,
|
||||
) {
|
||||
let pending = ""
|
||||
stream.setEncoding("utf8")
|
||||
stream.on("data", (chunk: string) => {
|
||||
pending += chunk
|
||||
const lines = pending.split(/\r?\n/g)
|
||||
pending = lines.pop() ?? ""
|
||||
for (const line of lines) onLine({ stream: source, text: line })
|
||||
})
|
||||
stream.on("end", () => {
|
||||
if (pending) onLine({ stream: source, text: pending })
|
||||
})
|
||||
}
|
||||
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
|
||||
const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
|
||||
return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
WslJob,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslRuntimeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
} from "../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "./constants"
|
||||
import { getStore } from "./store"
|
||||
import {
|
||||
installWslDistro,
|
||||
installWslOpencode,
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
listOnlineWslDistros,
|
||||
openWslTerminal,
|
||||
probeWslDistro,
|
||||
probeWslRuntime,
|
||||
readWslCommandVersion,
|
||||
resolveWslOpencode,
|
||||
summarize,
|
||||
upgradeWslOpencode,
|
||||
wslNeedsRestart,
|
||||
} from "./wsl"
|
||||
|
||||
type RunningSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
|
||||
|
||||
type ControllerLogger = {
|
||||
log: (message: string, meta?: unknown) => void
|
||||
error: (message: string, meta?: unknown) => void
|
||||
}
|
||||
|
||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
||||
|
||||
export function wslServerIdForDistro(distro: string) {
|
||||
return `wsl:${distro}`
|
||||
}
|
||||
|
||||
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
}
|
||||
|
||||
const setState = (next: Partial<WslServersState>) => {
|
||||
state = { ...state, ...next }
|
||||
emit()
|
||||
}
|
||||
|
||||
const persistServers = (servers: WslServerConfig[]) => {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
|
||||
setState({ servers: next })
|
||||
}
|
||||
|
||||
const beginJob = (job: WslJob): AbortController => {
|
||||
jobAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
jobAbort = abort
|
||||
setState({ job })
|
||||
return abort
|
||||
}
|
||||
|
||||
const endJob = (abort: AbortController) => {
|
||||
if (jobAbort !== abort) return
|
||||
jobAbort = undefined
|
||||
setState({ job: null })
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readPersistedServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
||||
return {
|
||||
config,
|
||||
runtime: existing?.runtime ?? { kind: "stopped" },
|
||||
}
|
||||
})
|
||||
setState({ servers: items })
|
||||
}
|
||||
|
||||
const setRuntime = (id: string, runtime: WslServerRuntime) => {
|
||||
updateServer(id, (item) => ({ ...item, runtime }))
|
||||
}
|
||||
|
||||
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[distro]: check,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await resolveWslOpencode(distro, opts)
|
||||
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
|
||||
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
|
||||
}
|
||||
|
||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
||||
const [installed, online] = await Promise.all([
|
||||
listInstalledWslDistros(opts),
|
||||
listOnlineWslDistros(opts),
|
||||
])
|
||||
return { installed, online }
|
||||
}
|
||||
|
||||
const nextStartAttempt = (id: string) => {
|
||||
const next = (startAttempts.get(id) ?? 0) + 1
|
||||
startAttempts.set(id, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const invalidateStartAttempt = (id: string) => {
|
||||
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const isCurrentStartAttempt = (id: string, attempt: number) => {
|
||||
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
|
||||
}
|
||||
|
||||
const startServer = async (id: string) => {
|
||||
const item = state.servers.find((x) => x.config.id === id)
|
||||
if (!item) return
|
||||
const attempt = nextStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "starting" })
|
||||
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
try {
|
||||
const sidecar = await spawnSidecar(item.config.distro)
|
||||
if (!isCurrentStartAttempt(id, attempt)) {
|
||||
try {
|
||||
sidecar.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors for stale sidecars
|
||||
}
|
||||
return
|
||||
}
|
||||
sidecars.set(id, sidecar)
|
||||
setRuntime(id, {
|
||||
kind: "ready",
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
sidecar.listener.onExit((code, signal) => {
|
||||
if (sidecars.get(id) !== sidecar) return
|
||||
sidecars.delete(id)
|
||||
const message = startupFailure(code, signal)
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
void refreshOpencodeCheck(item.config.distro).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
|
||||
})
|
||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
// Without this, an Ubuntu-style silent failure leaves no trace in
|
||||
// main.log — the controller captures the message in its state but
|
||||
// nothing surfaces unless the user opens the WSL servers dialog.
|
||||
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
}
|
||||
}
|
||||
|
||||
const stopServerInternal = async (id: string) => {
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
}
|
||||
|
||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
||||
const abort = beginJob(job)
|
||||
try {
|
||||
const value = await runner(abort)
|
||||
endJob(abort)
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
endJob(abort)
|
||||
return undefined
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
endJob(abort)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return state
|
||||
},
|
||||
subscribe(listener: (event: WslServersEvent) => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
|
||||
async initialize(opts?: { defaultServer?: string | null }) {
|
||||
refreshFromStore()
|
||||
if (opts?.defaultServer?.startsWith("wsl:")) void startServer(opts.defaultServer)
|
||||
},
|
||||
|
||||
async probeRuntime() {
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({
|
||||
runtime,
|
||||
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async refreshDistros() {
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
|
||||
setState(await refreshDistroLists({ signal: abort.signal }))
|
||||
})
|
||||
},
|
||||
|
||||
async installWsl() {
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslRuntimeElevated({ signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
const pendingRestart = wslNeedsRestart(result)
|
||||
setState({ pendingRestart })
|
||||
if (!pendingRestart) {
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({ runtime })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async installDistro(name: string) {
|
||||
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslDistro(name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
|
||||
throw new Error(message)
|
||||
}
|
||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
setState({
|
||||
...distros,
|
||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async probeDistro(name: string) {
|
||||
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const probe = await probeWslDistro(name, { signal: abort.signal })
|
||||
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
|
||||
})
|
||||
},
|
||||
|
||||
async probeOpencode(name: string) {
|
||||
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
})
|
||||
},
|
||||
|
||||
async installOpencode(name: string) {
|
||||
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const resolved = await resolveWslOpencode(name, { signal: abort.signal })
|
||||
const existingVersion = resolved
|
||||
? await readWslCommandVersion(resolved, name, { signal: abort.signal })
|
||||
: null
|
||||
const result =
|
||||
resolved && existingVersion
|
||||
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal })
|
||||
: await installWslOpencode(appVersion, name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
|
||||
}
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
})
|
||||
},
|
||||
|
||||
async openTerminal(name: string) {
|
||||
await openWslTerminal(name)
|
||||
},
|
||||
|
||||
async addServer(distro: string): Promise<WslServerConfig> {
|
||||
const id = wslServerIdForDistro(distro)
|
||||
if (state.servers.some((item) => item.config.id === id)) {
|
||||
throw new Error(`${distro} is already added`)
|
||||
}
|
||||
const config: WslServerConfig = {
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
persistServers([...readPersistedServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
void startServer(id)
|
||||
return config
|
||||
},
|
||||
|
||||
async removeServer(id: string) {
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
const remaining = readPersistedServers().filter((item) => item.id !== id)
|
||||
persistServers(remaining)
|
||||
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
|
||||
},
|
||||
|
||||
startServer,
|
||||
|
||||
stopAll() {
|
||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
||||
for (const existing of sidecars.values()) {
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sidecars.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function initialState(): WslServersState {
|
||||
return {
|
||||
runtime: null,
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
opencodeChecks: {},
|
||||
pendingRestart: false,
|
||||
servers: [],
|
||||
job: null,
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedServers(): WslServerConfig[] {
|
||||
const store = getStore()
|
||||
const existing = store.get(WSL_SERVERS_KEY)
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap(normalizePersistedServer)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [
|
||||
{
|
||||
id,
|
||||
distro,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function opencodeCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
version: string | null,
|
||||
expectedVersion: string,
|
||||
): WslOpencodeCheck {
|
||||
if (!resolvedPath) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath: null,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
matchesDesktop: null,
|
||||
error: "opencode is not installed in this distro",
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
matchesDesktop: null,
|
||||
error: "opencode is installed but could not run",
|
||||
}
|
||||
}
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version,
|
||||
expectedVersion,
|
||||
matchesDesktop: version === expectedVersion,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
||||
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
|
||||
}
|
||||
|
||||
// Re-export types used by callers
|
||||
export type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslRuntimeCheck,
|
||||
WslDistroProbe,
|
||||
WslOpencodeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
/** @ts-expect-error */
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
|
||||
|
||||
export type WslCommandLine = {
|
||||
stream: "stdout" | "stderr"
|
||||
text: string
|
||||
}
|
||||
|
||||
export type WslCommandResult = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export type RunWslOptions = {
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* Ceiling on how long we wait for the child process to exit. When the
|
||||
* LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
|
||||
* pending first-run prompt), `wsl.exe` never returns and any command
|
||||
* that doesn't specify a timeout hangs the entire startup flow. Default
|
||||
* is 20s — enough for slow cold-starts, short enough to fail fast on
|
||||
* a wedge. Callers can override for longer-running jobs.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
||||
}
|
||||
|
||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
||||
return runCommand("wsl", args, opts)
|
||||
}
|
||||
|
||||
function runPowerShell(command: string, opts: RunWslOptions = {}) {
|
||||
return runCommand(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
opts,
|
||||
)
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
signal: opts.signal,
|
||||
})
|
||||
|
||||
// Guard every wsl.exe invocation with a timeout. When the distro or
|
||||
// the LXSS service is wedged (Ubuntu first-run state, Windows update
|
||||
// pending, etc.) wsl.exe produces no output and never exits; without
|
||||
// this the whole sidecar spawn flow stalls the app forever.
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
const stdoutDecoder = createOutputDecoder()
|
||||
const stderrDecoder = createOutputDecoder()
|
||||
|
||||
const append = (stream: WslCommandLine["stream"], chunk: string) => {
|
||||
if (!chunk) return
|
||||
if (stream === "stdout") {
|
||||
stdout += chunk
|
||||
return
|
||||
}
|
||||
stderr += chunk
|
||||
}
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
append("stdout", stdoutDecoder.decode(chunk))
|
||||
})
|
||||
child.stdout.on("end", () => {
|
||||
append("stdout", stdoutDecoder.flush())
|
||||
})
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
append("stderr", stderrDecoder.decode(chunk))
|
||||
})
|
||||
child.stderr.on("end", () => {
|
||||
append("stderr", stderrDecoder.flush())
|
||||
})
|
||||
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve({ code, signal, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = pty.spawn(command, args, {
|
||||
name: "xterm-color",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
useConpty: true,
|
||||
})
|
||||
|
||||
let settled = false
|
||||
let stdout = ""
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId)
|
||||
abortCleanup?.()
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
const abortCleanup = opts.signal
|
||||
? (() => {
|
||||
opts.signal?.addEventListener("abort", abortHandler, { once: true })
|
||||
return () => opts.signal?.removeEventListener("abort", abortHandler)
|
||||
})()
|
||||
: undefined
|
||||
|
||||
child.onData((data: string) => {
|
||||
stdout += data
|
||||
})
|
||||
child.onExit((event: { exitCode: number }) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createOutputDecoder() {
|
||||
let decoder: TextDecoder | undefined
|
||||
return {
|
||||
decode(chunk: Buffer) {
|
||||
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
|
||||
return decoder.decode(chunk, { stream: true })
|
||||
},
|
||||
flush() {
|
||||
return decoder?.decode() ?? ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function detectOutputEncoding(chunk: Uint8Array) {
|
||||
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
|
||||
const pairs = Math.floor(chunk.length / 2)
|
||||
if (pairs < 2) return "utf-8"
|
||||
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
|
||||
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
|
||||
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
|
||||
}
|
||||
|
||||
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
|
||||
return runWsl(wslArgs(args, distro), opts)
|
||||
}
|
||||
|
||||
export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
|
||||
return runWslInDistro(["sh", "-lc", script], distro, opts)
|
||||
}
|
||||
|
||||
export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
|
||||
const version = await runWsl(["--version"], opts).catch((error) => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
|
||||
if (version.code !== 0) {
|
||||
return {
|
||||
available: false,
|
||||
version: null,
|
||||
error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
version: firstLine(version.stdout),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInstalledWslDistros(opts?: RunWslOptions) {
|
||||
const result = await runWsl(["--list", "--verbose"], opts)
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
|
||||
}
|
||||
return parseInstalledDistros(result.stdout)
|
||||
}
|
||||
|
||||
export async function listOnlineWslDistros(opts?: RunWslOptions) {
|
||||
const result = await runWsl(["--list", "--online"], opts)
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
|
||||
}
|
||||
return parseOnlineDistros(result.stdout)
|
||||
}
|
||||
|
||||
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
||||
].join("; ")
|
||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
wslArgs(["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`], distro),
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export function wslNeedsRestart(result: WslCommandResult) {
|
||||
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
|
||||
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
|
||||
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (executable.code !== 0) {
|
||||
return {
|
||||
name,
|
||||
canExecute: false,
|
||||
hasBash: false,
|
||||
hasCurl: false,
|
||||
error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
|
||||
}
|
||||
}
|
||||
|
||||
const [bash, curl] = await Promise.all([
|
||||
runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
|
||||
runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
|
||||
])
|
||||
|
||||
return {
|
||||
name,
|
||||
canExecute: true,
|
||||
hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
|
||||
hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWslHome(distro?: string | null, opts?: RunWslOptions) {
|
||||
return firstLine((await runWslSh('printf "%s\\n" "$HOME"', distro, opts)).stdout) ?? "/"
|
||||
}
|
||||
|
||||
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
const command = firstLine((await runWslSh("command -v opencode 2>/dev/null | grep -v '^/mnt/' | head -n 1 || true", distro, opts)).stdout)
|
||||
if (command) return command
|
||||
|
||||
for (const candidate of [
|
||||
'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
|
||||
'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
|
||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
|
||||
'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi',
|
||||
]) {
|
||||
const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
|
||||
return firstLine(result.stdout)
|
||||
}
|
||||
|
||||
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
wslArgs(["bash", "-lc", `${shellEscape(command)} upgrade ${shellEscape(target)}`], distro, "root"),
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
|
||||
return Promise.reject(new Error("Invalid distro name"))
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
child.once("error", reject)
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseInstalledDistros(output: string) {
|
||||
return output.split(/\r?\n/g).flatMap((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return []
|
||||
const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}\S+\s+(\d+)\s*$/)
|
||||
if (!match) return []
|
||||
const [, marker, name, version] = match
|
||||
if (!name || /^name$/i.test(name)) return []
|
||||
return [
|
||||
{
|
||||
name: name.trim(),
|
||||
version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
|
||||
isDefault: marker === "*",
|
||||
} satisfies WslInstalledDistro,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function parseOnlineDistros(output: string) {
|
||||
return output.split(/\r?\n/g).flatMap((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return []
|
||||
const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
|
||||
if (!match) return []
|
||||
const [, name, label] = match
|
||||
if (/^name$/i.test(name)) return []
|
||||
return [{ name, label: label.trim() } satisfies WslOnlineDistro]
|
||||
})
|
||||
}
|
||||
|
||||
function firstLine(value: string) {
|
||||
return (
|
||||
value
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function summarize(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function resolveSystem32Command(command: string) {
|
||||
const root = process.env.SystemRoot ?? process.env.windir
|
||||
if (!root) return command
|
||||
const resolved = join(root, "System32", command)
|
||||
return existsSync(resolved) ? resolved : command
|
||||
}
|
||||
|
||||
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
|
||||
return {
|
||||
...opts,
|
||||
timeoutMs: opts?.timeoutMs ?? timeoutMs,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
|
||||
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
|
||||
|
||||
const api: ElectronAPI = {
|
||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||
@@ -11,17 +11,38 @@ const api: ElectronAPI = {
|
||||
ipcRenderer.removeListener("init-step", handler)
|
||||
})
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
|
||||
subscribe: (cb) => {
|
||||
const handler = (_: unknown, event: WslServersEvent) => cb(event)
|
||||
ipcRenderer.on("wsl-servers-event", handler)
|
||||
void ipcRenderer.invoke("wsl-servers-subscribe")
|
||||
return () => {
|
||||
ipcRenderer.removeListener("wsl-servers-event", handler)
|
||||
void ipcRenderer.invoke("wsl-servers-unsubscribe")
|
||||
}
|
||||
},
|
||||
probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
|
||||
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
|
||||
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
|
||||
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
|
||||
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
|
||||
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
|
||||
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
|
||||
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
|
||||
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
|
||||
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
||||
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
|
||||
},
|
||||
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
|
||||
setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
|
||||
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
||||
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
||||
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
||||
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
|
||||
wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
|
||||
wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro),
|
||||
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
|
||||
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
|
||||
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
|
||||
|
||||
@@ -10,7 +10,87 @@ export type ServerReadyData = {
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
export type WslRuntimeCheck = {
|
||||
available: boolean
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslInstalledDistro = {
|
||||
name: string
|
||||
version: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
export type WslOnlineDistro = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
export type WslDistroProbe = {
|
||||
name: string
|
||||
canExecute: boolean
|
||||
hasBash: boolean
|
||||
hasCurl: boolean
|
||||
error: string | null
|
||||
}
|
||||
export type WslOpencodeCheck = {
|
||||
distro: string
|
||||
resolvedPath: string | null
|
||||
version: string | null
|
||||
expectedVersion: string | null
|
||||
matchesDesktop: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslServerConfig = {
|
||||
id: string
|
||||
distro: string
|
||||
}
|
||||
|
||||
export type WslServerRuntime =
|
||||
| { kind: "starting" }
|
||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
||||
| { kind: "failed"; message: string }
|
||||
| { kind: "stopped" }
|
||||
|
||||
export type WslServerItem = {
|
||||
config: WslServerConfig
|
||||
runtime: WslServerRuntime
|
||||
}
|
||||
|
||||
export type WslJob =
|
||||
| { kind: "runtime"; startedAt: number }
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
runtime: WslRuntimeCheck | null
|
||||
installed: WslInstalledDistro[]
|
||||
online: WslOnlineDistro[]
|
||||
distroProbes: Record<string, WslDistroProbe>
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
||||
pendingRestart: boolean
|
||||
servers: WslServerItem[]
|
||||
job: WslJob | null
|
||||
}
|
||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
||||
|
||||
export type WslServersAPI = {
|
||||
getState: () => Promise<WslServersState>
|
||||
subscribe: (cb: (event: WslServersEvent) => void) => () => void
|
||||
probeRuntime: () => Promise<void>
|
||||
refreshDistros: () => Promise<void>
|
||||
installWsl: () => Promise<void>
|
||||
installDistro: (name: string) => Promise<void>
|
||||
probeDistro: (name: string) => Promise<void>
|
||||
probeOpencode: (name: string) => Promise<void>
|
||||
installOpencode: (name: string) => Promise<void>
|
||||
openTerminal: (name: string) => Promise<void>
|
||||
addServer: (distro: string) => Promise<WslServerConfig>
|
||||
removeServer: (id: string) => Promise<void>
|
||||
startServer: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||
export type TitlebarTheme = {
|
||||
@@ -32,17 +112,16 @@ export type ElectronAPI = {
|
||||
killSidecar: () => Promise<void>
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
getWindowConfig: () => Promise<WindowConfig>
|
||||
consumeInitialDeepLinks: () => Promise<string[]>
|
||||
getDefaultServerUrl: () => Promise<string | null>
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
getWslConfig: () => Promise<WslConfig>
|
||||
setWslConfig: (config: WslConfig) => Promise<void>
|
||||
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
||||
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
||||
parseMarkdownCommand: (markdown: string) => Promise<string>
|
||||
checkAppExists: (appName: string) => Promise<boolean>
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
storeGet: (name: string, key: string) => Promise<string | null>
|
||||
storeSet: (name: string, key: string, value: string) => Promise<void>
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ declare global {
|
||||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
activeServer?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
useWslServers,
|
||||
} from "@opencode-ai/app"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
@@ -79,25 +81,26 @@ const createPlatform = (): Platform => {
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const isWslEnabled = async () => {
|
||||
if (os !== "windows") return false
|
||||
return window.api
|
||||
.getWslConfig()
|
||||
.then((config) => config.enabled)
|
||||
.catch(() => false)
|
||||
const activeWslDistro = () => {
|
||||
const key = window.__OPENCODE__?.activeServer
|
||||
if (!key || !key.startsWith("wsl:")) return undefined
|
||||
return key.slice("wsl:".length)
|
||||
}
|
||||
|
||||
const wslHome = async () => {
|
||||
if (!(await isWslEnabled())) return undefined
|
||||
return window.api.wslPath("~", "windows").catch(() => undefined)
|
||||
const distro = activeWslDistro()
|
||||
if (!distro) return undefined
|
||||
return window.api.wslPath("~", "windows", distro)
|
||||
}
|
||||
|
||||
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
|
||||
if (!result || !(await isWslEnabled())) return result
|
||||
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
|
||||
const distro = activeWslDistro()
|
||||
if (!result || !distro) return result
|
||||
const convert = (path: string) => window.api.wslPath(path, "linux", distro)
|
||||
if (Array.isArray(result)) {
|
||||
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
|
||||
return (await Promise.all(result.map(convert))) as T
|
||||
}
|
||||
return window.api.wslPath(result, "linux").catch(() => result) as any
|
||||
return (await convert(result)) as T
|
||||
}
|
||||
|
||||
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
|
||||
@@ -143,6 +146,8 @@ const createPlatform = (): Platform => {
|
||||
}
|
||||
})()
|
||||
|
||||
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
|
||||
|
||||
return {
|
||||
platform: "desktop",
|
||||
os,
|
||||
@@ -183,10 +188,8 @@ const createPlatform = (): Platform => {
|
||||
if (os === "windows") {
|
||||
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
||||
const resolvedPath = await (async () => {
|
||||
if (await isWslEnabled()) {
|
||||
const converted = await window.api.wslPath(path, "windows").catch(() => null)
|
||||
if (converted) return converted
|
||||
}
|
||||
const distro = activeWslDistro()
|
||||
if (distro) return window.api.wslPath(path, "windows", distro)
|
||||
return path
|
||||
})()
|
||||
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
|
||||
@@ -241,16 +244,7 @@ const createPlatform = (): Platform => {
|
||||
}
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
if (input instanceof Request) return fetch(input)
|
||||
return fetch(input, init)
|
||||
},
|
||||
|
||||
getWslEnabled: () => isWslEnabled(),
|
||||
|
||||
setWslEnabled: async (enabled) => {
|
||||
await window.api.setWslConfig({ enabled })
|
||||
},
|
||||
fetch,
|
||||
|
||||
getDefaultServer: async () => {
|
||||
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
||||
@@ -262,6 +256,8 @@ const createPlatform = (): Platform => {
|
||||
await window.api.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
wslServers: wslServersApi,
|
||||
|
||||
getDisplayBackend: async () => {
|
||||
return window.api.getDisplayBackend().catch(() => null)
|
||||
},
|
||||
@@ -303,7 +299,6 @@ listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
@@ -318,32 +313,11 @@ render(() => {
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
// Fetch sidecar credentials (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServer?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
if (!data) return []
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
}
|
||||
return [server] as ServerConnection.Any[]
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
@@ -370,6 +344,73 @@ render(() => {
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
const wslServers = useWslServers()
|
||||
const splash = (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ready = createMemo(
|
||||
() =>
|
||||
!defaultServer.loading &&
|
||||
!sidecar.loading &&
|
||||
!windowCount.loading &&
|
||||
!locale.loading,
|
||||
)
|
||||
const servers = createMemo(() => {
|
||||
const data = sidecar()
|
||||
const list: ServerConnection.Any[] = []
|
||||
if (data) {
|
||||
list.push({
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const item of wslServers.data?.servers ?? []) {
|
||||
const runtime = item.runtime
|
||||
if (runtime.kind !== "ready") continue
|
||||
list.push({
|
||||
displayName: item.config.distro,
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: item.config.distro,
|
||||
http: {
|
||||
url: runtime.url,
|
||||
username: runtime.username ?? undefined,
|
||||
password: runtime.password ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
const effectiveDefaultServer = createMemo(() => {
|
||||
const key = defaultServer.latest ?? ServerConnection.Key.make("sidecar")
|
||||
if (!key.startsWith("wsl:")) return key
|
||||
const item = wslServers.data?.servers.find((item) => item.config.id === key)
|
||||
if (item?.runtime.kind === "ready") return key
|
||||
return ServerConnection.Key.make("sidecar")
|
||||
})
|
||||
if (!ready()) return splash
|
||||
|
||||
return (
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
onCleanup(() => {
|
||||
@@ -380,27 +421,7 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show
|
||||
when={
|
||||
!defaultServer.loading &&
|
||||
!sidecar.loading &&
|
||||
!windowConfig.loading &&
|
||||
!windowCount.loading &&
|
||||
!locale.loading
|
||||
}
|
||||
>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<App />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
port: 3002,
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
@@ -163,6 +164,7 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.21.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPNextPermission } from "./permission"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate">
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
type GlobalEventEnvelope = {
|
||||
payload?: Event
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export class Subscription {
|
||||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly permission: ACPNextPermission.Handler
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
@@ -48,7 +51,9 @@ export class Subscription {
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.permission = new ACPNextPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
@@ -65,6 +70,9 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
return
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPNextSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-permission" })
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
export class Handler {
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event))
|
||||
.catch((error: unknown) => {
|
||||
log.error("failed to handle permission", { error, permissionID: permission.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
this.queues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
log.error("ACP connection cannot request permission", {
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
|
||||
log.error("failed to write proposed edit through ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
await this.input.sdk.permission.reply({
|
||||
requestID,
|
||||
reply,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
|
||||
const filepath = stringValue(metadata.filepath)
|
||||
const diff = stringValue(metadata.diff)
|
||||
if (!filepath || !diff || !this.input.connection.writeTextFile) return
|
||||
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) {
|
||||
log.error("Failed to apply unified diff (context mismatch)")
|
||||
return
|
||||
}
|
||||
|
||||
void this.input.connection.writeTextFile({
|
||||
sessionId,
|
||||
path: filepath,
|
||||
content: next,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
return "reject"
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPNextPermission from "./permission"
|
||||
@@ -0,0 +1,42 @@
|
||||
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
|
||||
const started = performance.now()
|
||||
|
||||
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
if (!enabled) return
|
||||
write(`${name}.mark`, performance.now() - started, fields)
|
||||
}
|
||||
|
||||
export function duration(
|
||||
name: string,
|
||||
startedAt: number,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return
|
||||
write(name, performance.now() - startedAt, fields)
|
||||
}
|
||||
|
||||
export async function measure<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
fields?: Record<string, string | number | boolean | undefined>,
|
||||
) {
|
||||
if (!enabled) return fn()
|
||||
const start = performance.now()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
write(name, performance.now() - start, fields)
|
||||
}
|
||||
}
|
||||
|
||||
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
|
||||
const extra = fields
|
||||
? Object.entries(fields)
|
||||
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")
|
||||
: ""
|
||||
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
|
||||
}
|
||||
|
||||
export * as ACPNextProfile from "./profile"
|
||||
@@ -35,9 +35,12 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPNextError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import { Directory } from "./directory"
|
||||
import { ACPNextEvent } from "./event"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { UsageService } from "./usage"
|
||||
import { ACPNextProfile } from "./profile"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { Command } from "@/command"
|
||||
@@ -46,6 +49,8 @@ export const AuthMethodID = "opencode-login"
|
||||
const log = Log.create({ service: "acp-next-service" })
|
||||
|
||||
export type Error = ACPNextError.Error
|
||||
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
export type Interface = {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
|
||||
@@ -69,20 +74,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: Pick<AgentSideConnection, "sessionUpdate">
|
||||
connection?: ServiceConnection
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
usage?: UsageService.Interface
|
||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||
}): Interface {
|
||||
const session = input.session ?? makeSessionService()
|
||||
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const sessionSnapshots = new Map<string, Directory.Snapshot>()
|
||||
const events = input.connection
|
||||
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
|
||||
: undefined
|
||||
if (events) input.eventSubscription?.(events)
|
||||
|
||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||
const started = performance.now()
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -99,7 +107,7 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const response = {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
@@ -124,6 +132,8 @@ export function make(input: {
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
ACPNextProfile.duration("acp.initialize", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||
@@ -134,15 +144,28 @@ export function make(input: {
|
||||
})
|
||||
|
||||
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
|
||||
return yield* directoryService.get(cwd)
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directoryService.get(cwd)
|
||||
ACPNextProfile.duration("acp.directory.snapshot", started)
|
||||
return snapshot
|
||||
})
|
||||
|
||||
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
|
||||
const snapshot = sessionSnapshots.get(state.id)
|
||||
if (snapshot) return snapshot
|
||||
const loaded = yield* directorySnapshot(state.cwd)
|
||||
sessionSnapshots.set(state.id, loaded)
|
||||
return loaded
|
||||
})
|
||||
|
||||
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const selected = selectDefaultModel(snapshot)
|
||||
const variant = selectVariant(snapshot, selected)
|
||||
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
|
||||
const created = yield* request(
|
||||
const created = yield* profiledRequest(
|
||||
"acp.newSession.session.create",
|
||||
() =>
|
||||
input.sdk.session.create(
|
||||
{
|
||||
@@ -166,11 +189,12 @@ export function make(input: {
|
||||
variant,
|
||||
modeId,
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
|
||||
return {
|
||||
const response = {
|
||||
sessionId: state.id,
|
||||
configOptions: configOptions(snapshot, {
|
||||
model: state.model ?? selected,
|
||||
@@ -178,6 +202,8 @@ export function make(input: {
|
||||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
ACPNextProfile.duration("acp.newSession", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||
@@ -204,6 +230,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -232,21 +259,35 @@ export function make(input: {
|
||||
),
|
||||
"session",
|
||||
)
|
||||
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
const serverEntries = sessions.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.directory,
|
||||
title: item.title,
|
||||
updatedAt: new Date(item.time.updated).toISOString(),
|
||||
}),
|
||||
)
|
||||
const liveEntries = (yield* session.list(params.cwd ?? undefined))
|
||||
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
|
||||
.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.cwd,
|
||||
updatedAt: item.createdAt.toISOString(),
|
||||
}),
|
||||
)
|
||||
const sorted = [...liveEntries, ...serverEntries].toSorted(
|
||||
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
|
||||
)
|
||||
const filtered =
|
||||
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
|
||||
cursor === undefined || !Number.isFinite(cursor)
|
||||
? sorted
|
||||
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
|
||||
const page = filtered.slice(0, limit)
|
||||
const last = page.at(-1)
|
||||
return {
|
||||
sessions: page.map(
|
||||
(item): SessionInfo => ({
|
||||
sessionId: item.id,
|
||||
cwd: item.directory,
|
||||
title: item.title,
|
||||
updatedAt: new Date(item.time.updated).toISOString(),
|
||||
}),
|
||||
),
|
||||
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
|
||||
sessions: page,
|
||||
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -274,6 +315,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -291,6 +333,7 @@ export function make(input: {
|
||||
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
sessionSnapshots.delete(params.sessionId)
|
||||
if (!removed) return {}
|
||||
|
||||
yield* request(
|
||||
@@ -334,6 +377,7 @@ export function make(input: {
|
||||
variant: restored.variant ?? selectVariant(snapshot, model),
|
||||
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
|
||||
})
|
||||
sessionSnapshots.set(state.id, snapshot)
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
@@ -353,7 +397,7 @@ export function make(input: {
|
||||
params: SetSessionConfigOptionRequest,
|
||||
) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (typeof params.value !== "string") {
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
@@ -408,7 +452,7 @@ export function make(input: {
|
||||
|
||||
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
|
||||
}
|
||||
@@ -418,7 +462,7 @@ export function make(input: {
|
||||
|
||||
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
const selected = yield* parseSelectedModel(snapshot, params.modelId)
|
||||
yield* session
|
||||
.setVariant(
|
||||
@@ -443,8 +487,81 @@ export function make(input: {
|
||||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const selected = current.model ?? selectDefaultModel(snapshot)
|
||||
if (!current.model) {
|
||||
yield* session.setModel(params.sessionId, selected)
|
||||
}
|
||||
const variant = current.variant ?? selectVariant(snapshot, selected)
|
||||
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
|
||||
const parts = promptContentToParts(params.prompt)
|
||||
const command = detectSlashCommand(parts)
|
||||
|
||||
if (!command) {
|
||||
const response = yield* request(
|
||||
() =>
|
||||
input.sdk.session.prompt(
|
||||
{
|
||||
sessionID: current.id,
|
||||
model: {
|
||||
providerID: selected.providerID,
|
||||
modelID: selected.modelID,
|
||||
},
|
||||
...(variant ? { variant } : {}),
|
||||
parts,
|
||||
...(modeId ? { agent: modeId } : {}),
|
||||
directory: current.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||
if (known) {
|
||||
const response = yield* request(
|
||||
() =>
|
||||
input.sdk.session.command(
|
||||
{
|
||||
sessionID: current.id,
|
||||
command: known.name,
|
||||
arguments: command.args,
|
||||
model: `${selected.providerID}/${selected.modelID}`,
|
||||
...(variant ? { variant } : {}),
|
||||
...(modeId ? { agent: modeId } : {}),
|
||||
directory: current.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
if (command.name === "compact") {
|
||||
yield* request(
|
||||
() =>
|
||||
input.sdk.session.summarize(
|
||||
{
|
||||
sessionID: current.id,
|
||||
directory: current.cwd,
|
||||
providerID: selected.providerID,
|
||||
modelID: selected.modelID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
)
|
||||
}
|
||||
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
@@ -473,6 +590,91 @@ function makeDirectoryService(sdk: OpencodeClient) {
|
||||
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
|
||||
}
|
||||
|
||||
function makeUsageService(sdk: OpencodeClient) {
|
||||
const limits = new Map<string, Promise<number | undefined>>()
|
||||
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
|
||||
function* (params) {
|
||||
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
|
||||
const current = limits.get(key)
|
||||
if (current) return yield* Effect.promise(() => current)
|
||||
|
||||
const next = sdk.config
|
||||
.providers({ directory: params.directory }, { throwOnError: true })
|
||||
.then((response) => {
|
||||
const providers = Object.fromEntries(
|
||||
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
|
||||
) as Record<ProviderID, Provider.Info>
|
||||
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
log.error("failed to get providers for usage context limit", { error })
|
||||
return undefined
|
||||
})
|
||||
limits.set(key, next)
|
||||
return yield* Effect.promise(() => next)
|
||||
},
|
||||
)
|
||||
|
||||
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
|
||||
function* (params) {
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
sdk.session.messages(
|
||||
{
|
||||
sessionID: params.sessionID,
|
||||
directory: params.directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch messages for usage update", { error })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!messages) return
|
||||
|
||||
const message = UsageService.latestAssistantMessage(messages)
|
||||
if (!message?.providerID || !message.modelID) return
|
||||
|
||||
const size = yield* contextLimit({
|
||||
directory: params.directory,
|
||||
providerID: ProviderID.make(message.providerID),
|
||||
modelID: ModelID.make(message.modelID),
|
||||
})
|
||||
if (!size) return
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
params.connection
|
||||
.sessionUpdate({
|
||||
sessionId: params.sessionID,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: message.tokens.input + message.tokens.cache.read,
|
||||
size,
|
||||
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send usage update", { error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return UsageService.Service.of({
|
||||
buildUsage: UsageService.buildUsage,
|
||||
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||
totalSessionCost: UsageService.totalSessionCost,
|
||||
contextLimit,
|
||||
sendUpdate,
|
||||
})
|
||||
}
|
||||
|
||||
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||
if (!subscription) return Effect.void
|
||||
return Effect.promise(async () => {
|
||||
@@ -505,6 +707,8 @@ type MessageInfo = {
|
||||
readonly agent?: Message["agent"]
|
||||
}
|
||||
|
||||
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
@@ -519,66 +723,79 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
|
||||
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||
sdk.command.list({ directory }, { throwOnError: true }),
|
||||
sdk.app.skills({ directory }, { throwOnError: true }),
|
||||
])
|
||||
const providersData = providersResponse.data!
|
||||
const agents = agentsResponse.data!
|
||||
const commandsData = commandsResponse.data!
|
||||
const skills = skillsResponse.data!
|
||||
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
|
||||
ProviderID,
|
||||
Provider.Info
|
||||
>
|
||||
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
|
||||
const modes = agents
|
||||
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
name: agent.name,
|
||||
...(agent.description ? { description: agent.description } : {}),
|
||||
}))
|
||||
const commands = [
|
||||
...commandsData,
|
||||
...skills
|
||||
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill" as const,
|
||||
template: skill.content,
|
||||
hints: [],
|
||||
})),
|
||||
] as Command.Info[]
|
||||
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return request(() => ACPNextProfile.measure(name, fn), service)
|
||||
}
|
||||
|
||||
return Directory.build({
|
||||
directory,
|
||||
providers,
|
||||
modes,
|
||||
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
return ACPNextProfile.measure("acp.directory.load", async () => {
|
||||
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
|
||||
ACPNextProfile.measure("acp.directory.provider.list", () =>
|
||||
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
|
||||
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.command.list", () =>
|
||||
sdk.command.list({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
|
||||
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
|
||||
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
|
||||
),
|
||||
])
|
||||
const providersData = providersResponse.data!
|
||||
const agents = agentsResponse.data!
|
||||
const commandsData = commandsResponse.data!
|
||||
const skills = skillsResponse.data!
|
||||
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
|
||||
ProviderID,
|
||||
Provider.Info
|
||||
>
|
||||
const defaultModelStarted = performance.now()
|
||||
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
|
||||
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
|
||||
const modes = agents
|
||||
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
name: agent.name,
|
||||
...(agent.description ? { description: agent.description } : {}),
|
||||
}))
|
||||
const commands = [
|
||||
...commandsData,
|
||||
...skills
|
||||
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill" as const,
|
||||
template: skill.content,
|
||||
hints: [],
|
||||
})),
|
||||
] as Command.Info[]
|
||||
|
||||
return Directory.build({
|
||||
directory,
|
||||
providers,
|
||||
modes,
|
||||
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
|
||||
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModelFromSdk(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
function defaultModelFromConfig(
|
||||
configuredModel: string | undefined,
|
||||
providers: Record<ProviderID, Provider.Info>,
|
||||
): Promise<Directory.DefaultModel | undefined> {
|
||||
const configured = await sdk.config
|
||||
.get({ directory }, { throwOnError: true })
|
||||
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
|
||||
.catch(() => undefined)
|
||||
): Directory.DefaultModel | undefined {
|
||||
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
|
||||
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
// First-session ACP startup must not scan historical sessions just to infer
|
||||
// a default. Configured model, opencode provider, then sorted best model keep
|
||||
// the protocol response deterministic without extra session/message reads.
|
||||
const opencodeProvider = providers[ProviderID.make("opencode")]
|
||||
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
|
||||
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
|
||||
@@ -588,30 +805,6 @@ async function defaultModelFromSdk(
|
||||
if (configured) return configured
|
||||
}
|
||||
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Record<ProviderID, Provider.Info>,
|
||||
): Promise<Directory.DefaultModel | undefined> {
|
||||
const session = await sdk.session
|
||||
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||
.then((response) => response.data?.[0])
|
||||
.catch(() => undefined)
|
||||
if (!session) return
|
||||
|
||||
const lastUser = await sdk.session
|
||||
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
|
||||
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
|
||||
.catch(() => undefined)
|
||||
if (lastUser?.role !== "user") return
|
||||
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
|
||||
|
||||
return {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||
if (snapshot.defaultModel) return snapshot.defaultModel
|
||||
const model = snapshot.modelOptions[0]
|
||||
@@ -619,6 +812,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
|
||||
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
|
||||
}
|
||||
|
||||
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
||||
const text = parts
|
||||
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
.trim()
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||
if (!name) return
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
function sendUsageUpdate(
|
||||
usage: UsageService.Interface | undefined,
|
||||
sdk: OpencodeClient,
|
||||
connection: ServiceConnection | undefined,
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
) {
|
||||
if (!connection) return Effect.void
|
||||
return (usage ?? makeUsageService(sdk)).sendUpdate({
|
||||
connection,
|
||||
sessionID,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
|
||||
const variants = Directory.variants(snapshot, model)
|
||||
if (!variants) return
|
||||
@@ -689,6 +919,7 @@ function registerMcpServers(
|
||||
sessionId: string,
|
||||
servers: readonly McpServer[],
|
||||
) {
|
||||
const started = performance.now()
|
||||
const current = registered.get(sessionId) ?? new Set<string>()
|
||||
registered.set(sessionId, current)
|
||||
const pending = new Set<string>()
|
||||
@@ -720,7 +951,16 @@ function registerMcpServers(
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() =>
|
||||
ACPNextProfile.duration("acp.mcp.register", started, {
|
||||
count: pending.size,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
|
||||
|
||||
@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
|
||||
export type Interface = {
|
||||
readonly create: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly load: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
|
||||
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
|
||||
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
@@ -168,6 +169,12 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
create: store,
|
||||
load: store,
|
||||
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
|
||||
return [...(yield* Ref.get(sessions)).values()]
|
||||
.filter((session) => !cwd || session.cwd === cwd)
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
}),
|
||||
get,
|
||||
tryGet,
|
||||
remove,
|
||||
|
||||
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return locationFrom(input.filePath)
|
||||
return locationFrom(input.filePath ?? input.filepath)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ACPNextProfile } from "@/acp-next/profile"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
@@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({
|
||||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
ACPNextProfile.mark("cli.acp.handler")
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const server = yield* Effect.promise(() =>
|
||||
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
|
||||
)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
@@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({
|
||||
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { cmd } from "./cmd"
|
||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
@@ -252,6 +253,7 @@ export const ProvidersListCommand = effectCmd({
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.list")(function* (_args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const authWellKnown = yield* AuthWellKnown.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
@@ -259,7 +261,8 @@ export const ProvidersListCommand = effectCmd({
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown")
|
||||
const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all()))
|
||||
const database = yield* modelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
@@ -267,7 +270,11 @@ export const ProvidersListCommand = effectCmd({
|
||||
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
yield* Prompt.outro(`${results.length} credentials`)
|
||||
for (const [url] of wellKnownResults) {
|
||||
yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`)
|
||||
}
|
||||
|
||||
yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
@@ -316,19 +323,19 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
}),
|
||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const authWellKnown = yield* AuthWellKnown.Service
|
||||
|
||||
UI.empty()
|
||||
yield* Prompt.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
|
||||
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
|
||||
)) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
const wellknown = yield* authWellKnown.metadata(url).pipe(
|
||||
Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })),
|
||||
)
|
||||
if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`)
|
||||
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const abort = new AbortController()
|
||||
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
||||
const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
||||
if (!proc.stdout) {
|
||||
yield* Prompt.log.error("Failed")
|
||||
yield* Prompt.outro("Done")
|
||||
@@ -342,7 +349,7 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||
yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() })))
|
||||
yield* Prompt.log.success("Logged into " + url)
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
@@ -492,10 +499,20 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
|
||||
const authSvc = yield* Auth.Service
|
||||
const authWellKnown = yield* AuthWellKnown.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
const credentials = [
|
||||
...Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
.filter(([, value]) => value.type !== "wellknown")
|
||||
.map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })),
|
||||
...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({
|
||||
key,
|
||||
type: "wellknown" as const,
|
||||
auth: "wellknown" as const,
|
||||
})),
|
||||
]
|
||||
yield* Prompt.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
yield* Prompt.log.error("No credentials found")
|
||||
@@ -504,12 +521,15 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||
const database = yield* modelsDev.get()
|
||||
const selected = yield* Prompt.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
options: credentials.map((item, index) => ({
|
||||
label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")",
|
||||
value: index,
|
||||
})),
|
||||
})
|
||||
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
|
||||
const credential = credentials[yield* promptValue(selected)]
|
||||
if (!credential) return
|
||||
if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key))
|
||||
else yield* Effect.orDie(authSvc.remove(credential.key))
|
||||
yield* Prompt.outro("Logout successful")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../server/server"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs).option("discoverable", {
|
||||
type: "boolean",
|
||||
describe: "write this server to the local discovery file for default TUI startup",
|
||||
default: false,
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
|
||||
if (discovery) {
|
||||
yield* discovery.write(server.url)
|
||||
process.on("exit", ServerDiscovery.removeSync)
|
||||
}
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
yield* Effect.never.pipe(
|
||||
Effect.ensuring(
|
||||
discovery
|
||||
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
@@ -79,8 +80,7 @@ import {
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.quick_switch.1",
|
||||
@@ -92,6 +92,10 @@ const appBindingCommands = [
|
||||
"session.quick_switch.7",
|
||||
"session.quick_switch.8",
|
||||
"session.quick_switch.9",
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -111,6 +115,7 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"workspace.list",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -603,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.list",
|
||||
title: "Manage workspaces",
|
||||
category: "Workspace",
|
||||
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "workspaces",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -914,6 +929,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.clear_prompt_history",
|
||||
title: kv.get("clear_prompt_save_history", false)
|
||||
? "Don't include cleared prompts in history"
|
||||
: "Include cleared prompts in history",
|
||||
category: "System",
|
||||
run: () => {
|
||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
].map((command) => ({
|
||||
namespace: "palette",
|
||||
...command,
|
||||
@@ -929,6 +955,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: () => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
if (store.history.at(-1)?.input === item.input) return
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
|
||||
@@ -157,6 +157,7 @@ export function Prompt(props: PromptProps) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -404,6 +405,15 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const promptCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
name: "permission.auto_accept.toggle",
|
||||
category: "Agent",
|
||||
run: () => {
|
||||
setAutoaccept((current) => (current === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
name: "prompt.clear",
|
||||
@@ -799,6 +809,67 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function expandPasteExtmark(extmark: { id: number; start: number; end: number }) {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
||||
if (part?.type !== "text" || !part.source?.text) return false
|
||||
|
||||
const nextInput = store.prompt.input.slice(0, extmark.start) + part.text + store.prompt.input.slice(extmark.end)
|
||||
const delta = part.text.length - (extmark.end - extmark.start)
|
||||
const nextParts = store.prompt.parts
|
||||
.flatMap((item, index) => {
|
||||
if (index === partIndex) return []
|
||||
const next = structuredClone(unwrap(item))
|
||||
if (next.type === "agent" && next.source && next.source.start >= extmark.end) {
|
||||
next.source.start += delta
|
||||
next.source.end += delta
|
||||
}
|
||||
if (next.type === "file" && next.source?.text && next.source.text.start >= extmark.end) {
|
||||
next.source.text.start += delta
|
||||
next.source.text.end += delta
|
||||
}
|
||||
if (next.type === "text" && next.source?.text && next.source.text.start >= extmark.end) {
|
||||
next.source.text.start += delta
|
||||
next.source.text.end += delta
|
||||
}
|
||||
return [next]
|
||||
})
|
||||
.filter((item): item is PromptInfo["parts"][number] => item !== undefined)
|
||||
|
||||
input.setText(nextInput)
|
||||
setStore("prompt", {
|
||||
input: nextInput,
|
||||
parts: nextParts,
|
||||
})
|
||||
restoreExtmarksFromParts(nextParts)
|
||||
input.cursorOffset = extmark.start + part.text.length
|
||||
return true
|
||||
}
|
||||
|
||||
function expandPasteBlockAtMouse(event: MouseEvent) {
|
||||
if (event.button !== 0) return false
|
||||
const localX = event.x - input.x
|
||||
const localY = event.y - input.y
|
||||
if (localX < 0 || localY < 0 || localX >= input.width || localY >= input.height) return false
|
||||
|
||||
const previousOffset = input.cursorOffset
|
||||
input.editorView.setLocalSelection(localX, localY, localX, localY, undefined, undefined, true, false)
|
||||
input.editorView.resetLocalSelection()
|
||||
const offset = input.cursorOffset
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((item) => {
|
||||
const partIndex = store.extmarkToPartIndex.get(item.id)
|
||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
||||
if (part?.type !== "text") return false
|
||||
return (offset >= item.start && offset <= item.end) || (offset + 1 >= item.start && offset + 1 <= item.end)
|
||||
})
|
||||
if (!extmark) {
|
||||
input.cursorOffset = previousOffset
|
||||
return false
|
||||
}
|
||||
|
||||
return expandPasteExtmark(extmark)
|
||||
}
|
||||
|
||||
const stashCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
@@ -1181,25 +1252,27 @@ export function Prompt(props: PromptProps) {
|
||||
})),
|
||||
})
|
||||
} else {
|
||||
const parts = [
|
||||
...editorParts,
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text" as const,
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map(assign),
|
||||
]
|
||||
const request = {
|
||||
sessionID,
|
||||
messageID,
|
||||
agent: agent.name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts,
|
||||
}
|
||||
sync.session.addOptimisticPrompt(request)
|
||||
sdk.client.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
messageID,
|
||||
agent: agent.name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
...editorParts,
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map(assign),
|
||||
],
|
||||
})
|
||||
.catch(() => {})
|
||||
.prompt(request)
|
||||
.catch(() => sync.session.removeOptimisticPrompt(request.sessionID, request.messageID))
|
||||
if (editorParts.length > 0) editor.markSelectionSent()
|
||||
}
|
||||
history.append({
|
||||
@@ -1372,7 +1445,8 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) {
|
||||
const shouldSave = store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0
|
||||
if (shouldSave || kv.get("clear_prompt_save_history", false)) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
@@ -1559,6 +1633,11 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (!expandPasteBlockAtMouse(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
@@ -1595,11 +1674,14 @@ export function Prompt(props: PromptProps) {
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={hasRightContent()}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
{props.right}
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={hasRightContent()}>{props.right}</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { CurrentWorkingDirectory } from "./cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
@@ -19,7 +20,6 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
||||
@@ -98,6 +98,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const substitution = yield* Substitution.Service
|
||||
let appliedOrder = 0
|
||||
|
||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||
@@ -112,9 +113,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
|
||||
)
|
||||
const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie)
|
||||
const data = ConfigParse.jsonc(expanded, configFilepath)
|
||||
if (!isRecord(data)) return {} as Info
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
@@ -295,7 +294,11 @@ export const layer = Layer.effect(
|
||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
|
||||
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
|
||||
|
||||
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
|
||||
return input.parts.map((part): Part => {
|
||||
const withIDs = {
|
||||
...part,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
}
|
||||
if (withIDs.type === "file") return { ...withIDs, url: "" }
|
||||
return withIDs
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeFetchedMessages(input: {
|
||||
currentMessages: Message[]
|
||||
currentParts: Record<string, Part[] | undefined>
|
||||
fetched: { info: Message; parts: Part[] }[]
|
||||
optimisticMessages: ReadonlySet<string>
|
||||
}) {
|
||||
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
|
||||
const messages = input.fetched.map((message) => message.info)
|
||||
const parts = new Map<string, Part[]>()
|
||||
const resolved = new Set<string>()
|
||||
|
||||
for (const message of input.currentMessages) {
|
||||
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
|
||||
Binary.insert(messages, message, (item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of input.fetched) {
|
||||
if (message.parts.length > 0) {
|
||||
resolved.add(message.info.id)
|
||||
parts.set(message.info.id, message.parts)
|
||||
continue
|
||||
}
|
||||
if (input.optimisticMessages.has(message.info.id)) {
|
||||
const current = input.currentParts[message.info.id]
|
||||
if (current) parts.set(message.info.id, current)
|
||||
continue
|
||||
}
|
||||
parts.set(message.info.id, message.parts)
|
||||
}
|
||||
|
||||
return { messages, parts, resolved }
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -111,8 +112,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
const optimisticMessages = new Set<string>()
|
||||
|
||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||
@@ -152,6 +155,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -219,6 +229,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
|
||||
case "session.deleted": {
|
||||
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
@@ -290,6 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
optimisticMessages.delete(event.properties.messageID)
|
||||
const messages = store.message[event.properties.sessionID]
|
||||
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
||||
if (result.found) {
|
||||
@@ -304,6 +316,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
optimisticMessages.delete(event.properties.part.messageID)
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
@@ -518,6 +531,66 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (last.role === "user") return "working"
|
||||
return last.time.completed ? "idle" : "working"
|
||||
},
|
||||
addOptimisticPrompt(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
parts: OptimisticPromptPart[]
|
||||
}) {
|
||||
optimisticMessages.add(input.messageID)
|
||||
const messages = store.message[input.sessionID]
|
||||
const match = messages ? Binary.search(messages, input.messageID, (m) => m.id) : undefined
|
||||
const info: Message = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: {
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.modelID,
|
||||
...(input.variant ? { variant: input.variant } : {}),
|
||||
},
|
||||
}
|
||||
batch(() => {
|
||||
if (!messages) {
|
||||
setStore("message", input.sessionID, [info])
|
||||
} else if (!match?.found) {
|
||||
setStore(
|
||||
"message",
|
||||
input.sessionID,
|
||||
produce((draft) => {
|
||||
Binary.insert(draft, info, (message) => message.id)
|
||||
}),
|
||||
)
|
||||
}
|
||||
setStore("part", input.messageID, reconcile(optimisticParts(input)))
|
||||
})
|
||||
},
|
||||
removeOptimisticPrompt(sessionID: string, messageID: string) {
|
||||
if (!optimisticMessages.delete(messageID)) return
|
||||
const messages = store.message[sessionID]
|
||||
const match = messages ? Binary.search(messages, messageID, (m) => m.id) : undefined
|
||||
batch(() => {
|
||||
if (match?.found) {
|
||||
setStore(
|
||||
"message",
|
||||
sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
produce((draft) => {
|
||||
delete draft[messageID]
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
if (fullSyncedSessions.has(sessionID)) return
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
@@ -529,15 +602,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
const merged = mergeFetchedMessages({
|
||||
currentMessages: draft.message[sessionID] ?? [],
|
||||
currentParts: draft.part,
|
||||
fetched: messages.data ?? [],
|
||||
optimisticMessages,
|
||||
})
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
const infos: (typeof draft.message)[string] = []
|
||||
for (const message of messages.data ?? []) {
|
||||
infos.push(message.info)
|
||||
draft.part[message.info.id] = message.parts
|
||||
draft.message[sessionID] = merged.messages
|
||||
for (const messageID of merged.resolved) {
|
||||
optimisticMessages.delete(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of merged.parts) {
|
||||
draft.part[messageID] = parts
|
||||
}
|
||||
draft.message[sessionID] = infos
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool
|
||||
: theme.warning
|
||||
|
||||
return (
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>{props.done ? "Thought" : "Thinking"}</span>
|
||||
<Show when={props.title}>
|
||||
<span>: </span>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={!props.done}>
|
||||
<box flexDirection="row">
|
||||
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title}>
|
||||
<span>: </span>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
@@ -133,12 +134,6 @@ const sessionBindingCommands = [
|
||||
"session.toggle.actions",
|
||||
"session.toggle.scrollbar",
|
||||
"session.toggle.generic_tool_output",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"session.messages_last_user",
|
||||
@@ -153,6 +148,17 @@ const sessionBindingCommands = [
|
||||
"session.child.previous",
|
||||
] as const
|
||||
|
||||
const sessionGlobalBindingCommands = [
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
] as const
|
||||
|
||||
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -409,15 +415,19 @@ export function Session() {
|
||||
|
||||
const local = useLocal()
|
||||
|
||||
function enterChild(sessionID: string) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
const status = sync.data.session_status[sessionID]
|
||||
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}
|
||||
|
||||
function moveFirstChild() {
|
||||
if (children().length === 1) return
|
||||
const next = children().find((x) => !!x.parentID)
|
||||
if (next) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: next.id,
|
||||
})
|
||||
}
|
||||
if (next) enterChild(next.id)
|
||||
}
|
||||
|
||||
function moveChild(direction: number) {
|
||||
@@ -428,12 +438,7 @@ export function Session() {
|
||||
|
||||
if (next >= sessions.length) next = 0
|
||||
if (next < 0) next = sessions.length - 1
|
||||
if (sessions[next]) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: sessions[next].id,
|
||||
})
|
||||
}
|
||||
if (sessions[next]) enterChild(sessions[next].id)
|
||||
}
|
||||
|
||||
function childSessionHandler(func: () => void) {
|
||||
@@ -1007,8 +1012,8 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
moveFirstChild()
|
||||
dialog.clear()
|
||||
moveFirstChild()
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1035,8 +1040,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(1)
|
||||
dialog.clear()
|
||||
moveChild(1)
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -1046,8 +1051,8 @@ export function Session() {
|
||||
hidden: true,
|
||||
enabled: !!session()?.parentID,
|
||||
run: childSessionHandler(() => {
|
||||
moveChild(-1)
|
||||
dialog.clear()
|
||||
moveChild(-1)
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -1067,6 +1072,15 @@ export function Session() {
|
||||
commands: sessionCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => renderer.currentFocusedEditor === null,
|
||||
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
|
||||
@@ -1510,6 +1524,8 @@ const PART_MAPPING = {
|
||||
reasoning: ReasoningPart,
|
||||
}
|
||||
|
||||
const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
@@ -1581,24 +1597,33 @@ function ReasoningHeader(props: {
|
||||
: theme.warning
|
||||
|
||||
return (
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>{props.done ? "Thought" : "Thinking"}</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={!props.done}>
|
||||
<box flexDirection="row">
|
||||
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1758,6 +1783,7 @@ function GenericTool(props: ToolProps<any>) {
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
complete: any
|
||||
pending: string
|
||||
spinner?: boolean
|
||||
@@ -1771,6 +1797,7 @@ function InlineTool(props: {
|
||||
const sync = useSync()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
|
||||
@@ -1778,13 +1805,6 @@ function InlineTool(props: {
|
||||
return callID === props.part.callID
|
||||
})
|
||||
|
||||
const fg = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (hover() && props.onClick) return theme.text
|
||||
if (props.complete) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
|
||||
|
||||
const denied = createMemo(
|
||||
@@ -1795,14 +1815,29 @@ function InlineTool(props: {
|
||||
error()?.includes("user dismissed"),
|
||||
)
|
||||
|
||||
const failed = createMemo(() => Boolean(error() && !denied()))
|
||||
const clickable = createMemo(() => Boolean(props.onClick || failed()))
|
||||
const fg = createMemo(() => {
|
||||
if (permission()) return theme.warning
|
||||
if (failed()) return theme.error
|
||||
if (props.color) return props.color
|
||||
if (hover() && props.onClick) return theme.text
|
||||
if (props.complete) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOver={() => clickable() && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (failed()) {
|
||||
setErrorExpanded((value) => !value)
|
||||
return
|
||||
}
|
||||
props.onClick?.()
|
||||
}}
|
||||
renderBefore={function () {
|
||||
@@ -1811,21 +1846,10 @@ function InlineTool(props: {
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
if (el.height > 1) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
const children = parent.getChildren()
|
||||
const index = children.indexOf(el)
|
||||
const previous = children[index - 1]
|
||||
if (!previous) {
|
||||
setMargin(0)
|
||||
return
|
||||
}
|
||||
if (previous.height > 1 || previous.id.startsWith("text-")) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
@@ -1833,15 +1857,37 @@ function InlineTool(props: {
|
||||
<Spinner color={fg()} children={props.children} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
<Show
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
|
||||
~ {props.pending}
|
||||
</text>
|
||||
}
|
||||
when={props.complete}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
width={INLINE_TOOL_ICON_WIDTH}
|
||||
fg={failed() ? theme.error : (props.iconColor ?? fg())}
|
||||
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.icon}
|
||||
</text>
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={failed() ? theme.error : fg()}
|
||||
attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={error() && !denied()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
<Show when={failed() && errorExpanded()}>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
@@ -1860,6 +1906,7 @@ function BlockTool(props: {
|
||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
|
||||
return (
|
||||
<box
|
||||
id={props.part ? "tool-block-" + props.part.id : undefined}
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -2063,8 +2110,10 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||
}
|
||||
|
||||
function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
|
||||
@@ -2086,6 +2135,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
)
|
||||
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const retry = createMemo(() => {
|
||||
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
|
||||
if (status?.type !== "retry") return
|
||||
return status
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
const first = messages().find((x) => x.role === "user")?.time.created
|
||||
@@ -2100,7 +2154,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
const retrying = retry()
|
||||
if (isRunning() && retrying) {
|
||||
content.push(`↳ ${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
|
||||
} else if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
if (current()) {
|
||||
const state = current()!.state
|
||||
@@ -2123,6 +2180,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
return (
|
||||
<InlineTool
|
||||
icon="│"
|
||||
color={retry() ? theme.error : undefined}
|
||||
spinner={isRunning()}
|
||||
complete={props.input.description}
|
||||
pending="Delegating..."
|
||||
@@ -2131,6 +2189,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
||||
if (props.metadata.sessionId) {
|
||||
navigate({ type: "session", sessionID: props.metadata.sessionId })
|
||||
}
|
||||
const status = retry()
|
||||
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
|
||||
}}
|
||||
>
|
||||
{content()}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
|
||||
network.mdns ||
|
||||
network.port !== 0 ||
|
||||
network.hostname !== "127.0.0.1"
|
||||
const discovered = external ? undefined : await ServerDiscovery.find()
|
||||
|
||||
const transport = external
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: discovered
|
||||
? {
|
||||
url: discovered,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
headers: undefined,
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
@@ -241,6 +254,7 @@ export const TuiThreadCommand = cmd({
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
details?: string[]
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -167,7 +169,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (!category) return acc
|
||||
return acc + (i > 0 ? 2 : 1)
|
||||
}, 0)
|
||||
return flat().length + headers
|
||||
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -426,7 +428,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
@@ -446,24 +448,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingLeft={current() || option.gutter ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
>
|
||||
<Show when={!current() && option.margin}>
|
||||
<box position="absolute" left={1} flexShrink={0}>
|
||||
{option.margin}
|
||||
</box>
|
||||
</Show>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={flatten() ? (option.category ?? option.footer) : option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={current()}
|
||||
gutter={option.gutter}
|
||||
/>
|
||||
</box>
|
||||
<For each={option.details}>
|
||||
{(detail) => (
|
||||
<box paddingLeft={3} paddingRight={3}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
export * as ServerDiscovery from "./server-discovery"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { readFileSync, unlinkSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export const file = path.join(Global.Path.state, "server.json")
|
||||
|
||||
const Entry = Schema.Struct({
|
||||
url: Schema.String,
|
||||
pid: Schema.Number,
|
||||
})
|
||||
type Entry = typeof Entry.Type
|
||||
const decodeEntry = Schema.decodeUnknownOption(Entry)
|
||||
|
||||
export interface Interface {
|
||||
readonly write: (url: URL) => Effect.Effect<void>
|
||||
readonly remove: () => Effect.Effect<void>
|
||||
readonly find: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const read = Effect.fn("CliServerDiscovery.read")(function* () {
|
||||
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return Option.getOrUndefined(decodeEntry(entry))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
|
||||
const entry = yield* read()
|
||||
if (entry?.pid !== process.pid) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
|
||||
const current = yield* read()
|
||||
if (current?.pid !== entry.pid || current.url !== entry.url) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
|
||||
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
|
||||
}),
|
||||
remove,
|
||||
find: Effect.fn("CliServerDiscovery.find")(function* () {
|
||||
const entry = yield* read()
|
||||
if (!entry) return undefined
|
||||
const url = yield* healthy(entry.url)
|
||||
if (url) return url
|
||||
yield* removeStale(entry)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const find = () => runPromise((discovery) => discovery.find())
|
||||
|
||||
export function removeSync() {
|
||||
const entry = readSync()
|
||||
if (entry?.pid !== process.pid) return
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readSync() {
|
||||
try {
|
||||
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function healthy(input: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const url = new URL(input)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
||||
const response = await fetch(new URL("/global/health", url), {
|
||||
headers: ServerAuth.headers(),
|
||||
signal: AbortSignal.timeout(1000),
|
||||
})
|
||||
if (!response.ok) return undefined
|
||||
const body = (await response.json()) as unknown
|
||||
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
|
||||
return url.toString()
|
||||
}
|
||||
},
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function localURL(url: URL) {
|
||||
const result = new URL(url)
|
||||
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
|
||||
if (result.hostname === "::") result.hostname = "::1"
|
||||
return result
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import fsNode from "fs/promises"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
@@ -19,7 +20,6 @@ import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
@@ -40,9 +40,7 @@ import { ConfigProvider } from "./provider"
|
||||
import { ConfigReference } from "./reference"
|
||||
import { ConfigServer } from "./server"
|
||||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -72,48 +70,6 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
||||
return copy
|
||||
}
|
||||
|
||||
async function substituteWellKnownRemoteConfig(input: {
|
||||
value: unknown
|
||||
dir: string
|
||||
source: string
|
||||
env: Record<string, string>
|
||||
}) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
|
||||
|
||||
const url = await ConfigVariable.substitute({
|
||||
text: input.value.url,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
})
|
||||
const headers = isRecord(input.value.headers)
|
||||
? Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(input.value.headers)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(async ([key, value]) => [
|
||||
key,
|
||||
await ConfigVariable.substitute({
|
||||
text: value,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return { url, headers }
|
||||
}
|
||||
|
||||
const WellKnownConfig = Schema.Struct({
|
||||
config: Schema.optional(Schema.Json),
|
||||
remote_config: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
@@ -382,44 +338,22 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const authWellKnown = yield* AuthWellKnown.Service
|
||||
const substitution = yield* Substitution.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
|
||||
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
|
||||
url: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
schema: S,
|
||||
) {
|
||||
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||
.execute(
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
env?: Record<string, string>,
|
||||
) {
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute(
|
||||
"path" in options
|
||||
? { text, type: "path", path: options.path, env }
|
||||
: { text, type: "virtual", ...options, env },
|
||||
),
|
||||
)
|
||||
const expanded = yield* substitution.substitute(
|
||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
||||
).pipe(Effect.orDie)
|
||||
const parsed = ConfigParse.jsonc(expanded, source)
|
||||
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
|
||||
if (!("path" in options)) return data
|
||||
@@ -433,14 +367,14 @@ export const layer = Layer.effect(
|
||||
return data
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath }, env)
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = {}
|
||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
||||
// explicitly routes config through env-provided paths or content.
|
||||
@@ -452,9 +386,9 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
}
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
@@ -509,10 +443,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
|
||||
function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
const authEnv: Record<string, string> = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
@@ -549,59 +480,26 @@ export const layer = Layer.effect(
|
||||
return mergePluginOrigins(source, next.plugin, kind)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
authEnv[value.key] = value.token
|
||||
const wellknownURL = `${url}/.well-known/opencode`
|
||||
log.debug("fetching remote config", { url: wellknownURL })
|
||||
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
|
||||
const remote = yield* Effect.promise(() =>
|
||||
substituteWellKnownRemoteConfig({
|
||||
value: wellknown.remote_config,
|
||||
dir: url,
|
||||
source: wellknownURL,
|
||||
env: authEnv,
|
||||
}),
|
||||
)
|
||||
const fetchedConfig = remote
|
||||
? yield* Effect.gen(function* () {
|
||||
log.debug("fetching remote config", { url: remote.url })
|
||||
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
|
||||
if (isRecord(data) && isRecord(data.config)) return data.config
|
||||
if (isRecord(data)) return data
|
||||
return yield* Effect.die(
|
||||
new Error(`failed to decode remote config from ${remote.url}: expected object`),
|
||||
)
|
||||
})
|
||||
: {}
|
||||
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = wellknownURL
|
||||
const next = yield* loadConfig(
|
||||
JSON.stringify(remoteConfig),
|
||||
{
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
},
|
||||
authEnv,
|
||||
)
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) {
|
||||
yield* merge(
|
||||
item.source,
|
||||
yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }),
|
||||
"global",
|
||||
)
|
||||
log.debug("loaded well-known config", { url: item.url })
|
||||
}
|
||||
|
||||
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
|
||||
const global = yield* getGlobal()
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
||||
yield* merge(file, yield* loadFile(file, authEnv), "local")
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +520,7 @@ export const layer = Layer.effect(
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
yield* merge(source, yield* loadFile(source, authEnv))
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
@@ -875,10 +773,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(AuthWellKnown.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export * as Config from "./config"
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
export * as ConfigVariable from "./variable"
|
||||
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InvalidError } from "./error"
|
||||
|
||||
type ParseSource =
|
||||
| {
|
||||
type: "path"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
type: "virtual"
|
||||
source: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return input.type === "path" ? input.path : input.source
|
||||
}
|
||||
|
||||
function dir(input: ParseSource) {
|
||||
return input.type === "path" ? path.dirname(input.path) : input.dir
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
export async function substitute(input: SubstituteInput) {
|
||||
const missing = input.missing ?? "error"
|
||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return (input.env?.[varName] ?? process.env[varName]) || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = dir(input)
|
||||
const configSource = source(input)
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (missing === "empty") return ""
|
||||
|
||||
const errMsg = `bad file reference: "${token}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
|
||||
out += JSON.stringify(fileContent).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { attach } from "./run-service"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Bus } from "@/bus"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account/account"
|
||||
@@ -62,6 +63,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
AuthWellKnown.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
|
||||
@@ -54,6 +54,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
@@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -57,18 +58,28 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
|
||||
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
|
||||
}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||
return [
|
||||
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
|
||||
(input) =>
|
||||
CodexAuthPlugin(input, {
|
||||
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
||||
}),
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
]
|
||||
}
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -151,7 +162,7 @@ export const layer = Layer.effect(
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = yield* Effect.tryPromise({
|
||||
try: () => plugin(input),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# OpenAI Responses WebSocket
|
||||
|
||||
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
|
||||
|
||||
## Flow
|
||||
|
||||
1. A streamed `POST /responses` request arrives.
|
||||
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
|
||||
3. Title requests use HTTP.
|
||||
4. If that session's socket is busy or already in fallback mode, use HTTP.
|
||||
5. Otherwise, reuse its open socket or open a new one.
|
||||
6. Send `response.create` and return WebSocket events as SSE.
|
||||
|
||||
## Lifetime
|
||||
|
||||
- Connect timeout: 15 seconds.
|
||||
- Idle timeout: 5 minutes.
|
||||
- After a completed response, keep the socket for reuse.
|
||||
- Reuse a socket for up to 55 minutes, then replace it on the next request.
|
||||
|
||||
## Retries
|
||||
|
||||
- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned.
|
||||
- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback.
|
||||
- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport.
|
||||
- Abort or cancel closes the socket.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `previous_response_id` continuation.
|
||||
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
|
||||
+31
-30
@@ -1,10 +1,11 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import os from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -28,20 +29,12 @@ interface PkceCodes {
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(43)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64UrlEncode(hash)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
@@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
export interface IdTokenClaims {
|
||||
chatgpt_account_id?: string
|
||||
organizations?: Array<{ id: string }>
|
||||
@@ -120,6 +109,7 @@ interface TokenResponse {
|
||||
interface CodexAuthPluginOptions {
|
||||
issuer?: string
|
||||
codexApiEndpoint?: string
|
||||
experimentalWebSockets?: boolean
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
@@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||
const issuer = options.issuer ?? ISSUER
|
||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||
let websocketFetchInstalled = false
|
||||
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
||||
|
||||
return {
|
||||
async dispose() {
|
||||
for (const websocketFetch of websocketFetches) websocketFetch.close()
|
||||
websocketFetches.length = 0
|
||||
},
|
||||
provider: {
|
||||
id: "openai",
|
||||
async models(provider, ctx) {
|
||||
@@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
provider: "openai",
|
||||
async loader(getAuth) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
const websocketFetch = options.experimentalWebSockets
|
||||
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
|
||||
: undefined
|
||||
if (websocketFetch) {
|
||||
websocketFetches.push(websocketFetch)
|
||||
websocketFetchInstalled = true
|
||||
}
|
||||
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
@@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
// Remove dummy API key authorization header
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.delete("authorization")
|
||||
@@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
if (currentAuth.type !== "oauth")
|
||||
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
|
||||
|
||||
// Cast to include accountId field
|
||||
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
if (!refreshPromise) {
|
||||
log.info("refreshing codex access token")
|
||||
@@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authWithAccount.accountId = refreshed.accountId
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = new Headers()
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
@@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set authorization header with access token
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
|
||||
// Set ChatGPT-Account-Id header for organization subscriptions
|
||||
if (authWithAccount.accountId) {
|
||||
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
||||
}
|
||||
|
||||
// Rewrite URL to Codex endpoint
|
||||
const parsed =
|
||||
requestInput instanceof URL
|
||||
? requestInput
|
||||
@@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
? new URL(codexApiEndpoint)
|
||||
: parsed
|
||||
|
||||
return fetch(url, {
|
||||
const requestInit = {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
@@ -639,6 +636,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
output.headers["session-id"] = input.sessionID
|
||||
// Temporary fetch-layer hack: title generation currently shares the conversation
|
||||
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
||||
// context can be passed directly instead of smuggled through headers.
|
||||
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
|
||||
},
|
||||
"chat.params": async (input, output) => {
|
||||
if (input.model.providerID !== "openai") return
|
||||
@@ -0,0 +1,272 @@
|
||||
import WebSocket from "ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { OpenAIWebSocket } from "./ws"
|
||||
|
||||
export const TITLE_HEADER = "x-opencode-title"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.ws" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
httpFetch?: typeof globalThis.fetch
|
||||
url?: string
|
||||
connectTimeout?: number
|
||||
idleTimeout?: number
|
||||
maxConnectionAge?: number
|
||||
streamRetries?: number
|
||||
}
|
||||
|
||||
interface PoolEntry {
|
||||
socket?: WebSocket
|
||||
connectedAt?: number
|
||||
lastUsedAt: number
|
||||
busy: boolean
|
||||
fallback: boolean
|
||||
streamFailures: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT = 15_000
|
||||
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
|
||||
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
|
||||
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
const httpFetch = options?.httpFetch ?? globalThis.fetch
|
||||
const pool = new Map<string, PoolEntry>()
|
||||
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
|
||||
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
|
||||
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
|
||||
const streamRetries = options?.streamRetries ?? 5
|
||||
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
|
||||
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
|
||||
pruneTimer.unref()
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
|
||||
const httpInit = withoutInternalHeaders(init)
|
||||
|
||||
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const body = (() => {
|
||||
try {
|
||||
if (typeof init?.body !== "string") return undefined
|
||||
const parsed = JSON.parse(init.body)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!body?.stream) return httpFetch(input, httpInit)
|
||||
if (internalHeaders[TITLE_HEADER] === "true") {
|
||||
log.debug("http fallback", { reason: "title" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
|
||||
if (!sessionID) {
|
||||
log.debug("http fallback", { reason: "missing_session" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
const key = `${sessionID}:conversation`
|
||||
|
||||
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
|
||||
pool.set(key, entry)
|
||||
|
||||
if (entry.fallback) {
|
||||
log.debug("http fallback", { key, reason: "fallback_active" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
if (entry.busy) {
|
||||
log.debug("http fallback", { key, reason: "busy" })
|
||||
return httpFetch(input, httpInit)
|
||||
}
|
||||
|
||||
entry.busy = true
|
||||
entry.lastUsedAt = Date.now()
|
||||
try {
|
||||
entry.socket = await socket(
|
||||
entry,
|
||||
options?.url ?? url,
|
||||
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
|
||||
connectTimeout,
|
||||
maxConnectionAge,
|
||||
init?.signal,
|
||||
)
|
||||
let resolveFirstEvent: (started: boolean) => void = () => {}
|
||||
let rejectFirstEvent: (error: Error) => void = () => {}
|
||||
const firstEvent = new Promise<boolean>((resolve, reject) => {
|
||||
resolveFirstEvent = resolve
|
||||
rejectFirstEvent = reject
|
||||
})
|
||||
const response = OpenAIWebSocket.streamResponsesWebSocket({
|
||||
socket: entry.socket,
|
||||
body,
|
||||
idleTimeout,
|
||||
signal: init?.signal ?? undefined,
|
||||
onFirstEvent: () => resolveFirstEvent(true),
|
||||
onTerminal: (event) => {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
entry.streamFailures = 0
|
||||
if (event.type !== "response.completed" && event.type !== "response.done") {
|
||||
log.warn("websocket terminal failure", { key, type: event.type })
|
||||
invalidate(entry)
|
||||
}
|
||||
},
|
||||
onConnectionInvalid: (error) => {
|
||||
log.warn("websocket invalidated", { key, error: error.message })
|
||||
entry.busy = false
|
||||
if (!entry.fallback) recordStreamFailure(entry)
|
||||
invalidate(entry)
|
||||
resolveFirstEvent(false)
|
||||
},
|
||||
onAbort: (error) => {
|
||||
log.debug("websocket aborted", { key })
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
entry.streamFailures = 0
|
||||
invalidate(entry)
|
||||
rejectFirstEvent(error)
|
||||
},
|
||||
onRetryableTerminal: async (event) => {
|
||||
const error = connectionLimitError(event)
|
||||
if (!error) return undefined
|
||||
log.warn("websocket connection limit reached", { key })
|
||||
throw error
|
||||
},
|
||||
})
|
||||
if (await firstEvent) return response
|
||||
if (!entry.fallback) return response
|
||||
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
|
||||
return httpFetch(input, httpInit)
|
||||
} catch (error) {
|
||||
entry.busy = false
|
||||
entry.lastUsedAt = Date.now()
|
||||
if (OpenAIWebSocket.isAbortError(error)) {
|
||||
entry.streamFailures = 0
|
||||
invalidate(entry)
|
||||
throw error
|
||||
}
|
||||
|
||||
recordStreamFailure(entry)
|
||||
log.warn("websocket setup failed", {
|
||||
key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fallback: entry.fallback ? "http" : undefined,
|
||||
})
|
||||
invalidate(entry)
|
||||
if (entry.fallback) return httpFetch(input, httpInit)
|
||||
return failedResponse(
|
||||
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
||||
cause: error,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function recordStreamFailure(entry: PoolEntry) {
|
||||
entry.streamFailures++
|
||||
// Codex counts retries after the initial failed WebSocket attempt.
|
||||
if (entry.streamFailures > streamRetries) entry.fallback = true
|
||||
}
|
||||
|
||||
function prune() {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pool) {
|
||||
if (entry.busy) continue
|
||||
if (now - entry.lastUsedAt < idleTimeout) continue
|
||||
log.debug("websocket idle prune", { key })
|
||||
invalidate(entry)
|
||||
pool.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
log.debug("websocket pool close", { count: pool.size })
|
||||
clearInterval(pruneTimer)
|
||||
for (const entry of pool.values()) invalidate(entry)
|
||||
pool.clear()
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, { close })
|
||||
}
|
||||
|
||||
function connectionLimitError(event: Record<string, unknown>) {
|
||||
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
|
||||
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
|
||||
}
|
||||
|
||||
function failedResponse(error: ProviderError.ResponseStreamError) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(error)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function socket(
|
||||
entry: PoolEntry,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
connectTimeout: number,
|
||||
maxConnectionAge: number,
|
||||
signal?: AbortSignal | null,
|
||||
) {
|
||||
if (
|
||||
entry.socket?.readyState === WebSocket.OPEN &&
|
||||
entry.connectedAt &&
|
||||
Date.now() - entry.connectedAt < maxConnectionAge
|
||||
) {
|
||||
return entry.socket
|
||||
}
|
||||
|
||||
invalidate(entry)
|
||||
const next = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||
url: OpenAIWebSocket.toWebSocketUrl(url),
|
||||
headers,
|
||||
timeout: connectTimeout,
|
||||
signal: signal ?? undefined,
|
||||
})
|
||||
entry.connectedAt = Date.now()
|
||||
return next
|
||||
}
|
||||
|
||||
function invalidate(entry: PoolEntry) {
|
||||
if (entry.socket) {
|
||||
entry.socket.on("error", () => {})
|
||||
entry.socket.terminate()
|
||||
entry.socket = undefined
|
||||
}
|
||||
entry.connectedAt = undefined
|
||||
}
|
||||
|
||||
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
|
||||
if (!init?.headers) return init
|
||||
if (init.headers instanceof Headers) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.delete(TITLE_HEADER)
|
||||
return { ...init, headers }
|
||||
}
|
||||
|
||||
if (Array.isArray(init.headers)) {
|
||||
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
|
||||
}
|
||||
|
||||
return {
|
||||
...init,
|
||||
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocketPool from "./ws-pool"
|
||||
@@ -0,0 +1,326 @@
|
||||
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
|
||||
// fallback, and continuation state intentionally live above this file.
|
||||
|
||||
import WebSocket from "ws"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
|
||||
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
|
||||
|
||||
export interface ConnectResponsesWebSocketOptions {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface StreamResponsesWebSocketOptions {
|
||||
socket: WebSocket
|
||||
body: Record<string, unknown>
|
||||
idleTimeout?: number
|
||||
signal?: AbortSignal
|
||||
onFirstEvent?: () => void
|
||||
onComplete?: (event: Record<string, unknown>) => void
|
||||
onTerminal?: (event: Record<string, unknown>) => void
|
||||
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
|
||||
onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void
|
||||
onAbort?: (error: Error) => void
|
||||
}
|
||||
|
||||
export function toWebSocketUrl(url: string) {
|
||||
return url.replace(/^http/, "ws")
|
||||
}
|
||||
|
||||
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
result[key.toLowerCase()] = value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value != null) result[key.toLowerCase()] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): error is DOMException {
|
||||
return error instanceof DOMException && error.name === "AbortError"
|
||||
}
|
||||
|
||||
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(abortError(options.signal))
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
|
||||
}
|
||||
delete headers["content-length"]
|
||||
|
||||
const socket = new WebSocket(options.url, { headers })
|
||||
const timeout = options.timeout
|
||||
? setTimeout(() => {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(new Error("WebSocket connect timed out"))
|
||||
}, options.timeout)
|
||||
: undefined
|
||||
|
||||
function cleanup() {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
socket.off("open", onOpen)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
cleanup()
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
socket.on("error", () => {})
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
cleanup()
|
||||
reject(new Error(closeMessage("WebSocket closed before open", code, reason)))
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
cleanup()
|
||||
socket.on("error", () => {})
|
||||
socket.terminate()
|
||||
reject(abortError(options.signal))
|
||||
}
|
||||
|
||||
socket.once("open", onOpen)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
let socket = options.socket
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let cleanupSocket = () => {}
|
||||
let completed = false
|
||||
let emitted = false
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function cleanup() {
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
cleanupSocket()
|
||||
options.signal?.removeEventListener("abort", onAbort)
|
||||
}
|
||||
|
||||
function terminateSocket(target = socket) {
|
||||
target.on("error", () => {})
|
||||
target.terminate()
|
||||
}
|
||||
|
||||
function closeCompleted() {
|
||||
cleanup()
|
||||
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller?.close()
|
||||
}
|
||||
|
||||
function invalidate(error: ProviderError.ResponseStreamError) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
options.onConnectionInvalid?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function resetIdleTimeout(message: string) {
|
||||
if (completed) return
|
||||
if (!options.idleTimeout) return
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout)
|
||||
}
|
||||
|
||||
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
|
||||
if (completed) return
|
||||
if (isBinary) {
|
||||
invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame"))
|
||||
return
|
||||
}
|
||||
|
||||
const text = data.toString()
|
||||
const event = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
|
||||
cleanupSocket()
|
||||
if (idleTimer) clearTimeout(idleTimer)
|
||||
idleTimer = undefined
|
||||
try {
|
||||
const next = await options.onRetryableTerminal(event)
|
||||
if (completed) {
|
||||
if (next) terminateSocket(next)
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
attach(next)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
invalidate(
|
||||
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
||||
cause: error,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted) options.onFirstEvent?.()
|
||||
controller?.enqueue(
|
||||
encoder.encode(
|
||||
`${text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `data: ${line}`)
|
||||
.join("\n")}\n\n`,
|
||||
),
|
||||
)
|
||||
emitted = true
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
|
||||
if (!event) return
|
||||
|
||||
if (event.type === "response.completed" || event.type === "response.done") {
|
||||
completed = true
|
||||
options.onComplete?.(event)
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
|
||||
completed = true
|
||||
options.onTerminal?.(event)
|
||||
closeCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
function onError(error: Error) {
|
||||
invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
|
||||
}
|
||||
|
||||
function onClose(code: number, reason: Buffer) {
|
||||
if (completed) return
|
||||
invalidate(
|
||||
new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)),
|
||||
)
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
const error = abortError(options.signal)
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(error)
|
||||
controller?.error(error)
|
||||
}
|
||||
|
||||
function onCancel(reason: unknown) {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
terminateSocket()
|
||||
options.onAbort?.(cancelError(reason))
|
||||
}
|
||||
|
||||
function attach(next: WebSocket) {
|
||||
cleanupSocket()
|
||||
socket = next
|
||||
socket.on("message", onMessage)
|
||||
socket.once("error", onError)
|
||||
socket.once("close", onClose)
|
||||
cleanupSocket = () => {
|
||||
socket.off("message", onMessage)
|
||||
socket.off("error", onError)
|
||||
socket.off("close", onClose)
|
||||
}
|
||||
const { stream: _stream, background: _background, ...payload } = options.body
|
||||
resetIdleTimeout("idle timeout sending websocket request")
|
||||
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
|
||||
if (completed) return
|
||||
resetIdleTimeout("idle timeout waiting for websocket")
|
||||
if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(next) {
|
||||
controller = next
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true })
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
attach(socket)
|
||||
},
|
||||
cancel(reason) {
|
||||
onCancel(reason)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function cancelError(reason: unknown) {
|
||||
if (isAbortError(reason)) return reason
|
||||
if (reason instanceof Error) return reason
|
||||
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal | undefined) {
|
||||
const reason = signal?.reason
|
||||
if (isAbortError(reason)) return reason
|
||||
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
|
||||
}
|
||||
|
||||
function closeMessage(message: string, code: number, reason: Buffer) {
|
||||
const details = [`code ${code}`]
|
||||
if (code === 1009) details.push("message too big")
|
||||
if (reason.length > 0) details.push(reason.toString())
|
||||
return `${message} (${details.join(": ")})`
|
||||
}
|
||||
|
||||
export * as OpenAIWebSocket from "./ws"
|
||||
@@ -11,6 +11,14 @@ export class HeaderTimeoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseStreamError extends Error {
|
||||
public override readonly name = "ProviderResponseStreamError"
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from overflow detection patterns in:
|
||||
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
|
||||
const OVERFLOW_PATTERNS = [
|
||||
|
||||
@@ -596,8 +596,16 @@ function openaiCompatibleReasoningEfforts(id: string) {
|
||||
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
|
||||
}
|
||||
|
||||
function anthropicOpus47OrLater(apiId: string) {
|
||||
const version = /opus-(\d+)[.-](\d+)(?:[.-]|$)/i.exec(apiId)
|
||||
if (!version) return false
|
||||
const major = Number(version[1])
|
||||
const minor = Number(version[2])
|
||||
return major > 4 || (major === 4 && minor >= 7)
|
||||
}
|
||||
|
||||
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
|
||||
if (anthropicOpus47OrLater(apiId)) {
|
||||
return ["low", "medium", "high", "xhigh", "max"]
|
||||
}
|
||||
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
|
||||
@@ -625,6 +633,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
if (!model.capabilities.reasoning) return {}
|
||||
|
||||
const id = model.id.toLowerCase()
|
||||
const adaptiveOpus = anthropicOpus47OrLater(model.api.id)
|
||||
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
|
||||
if (
|
||||
id.includes("deepseek-chat") ||
|
||||
@@ -833,9 +842,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
|
||||
? { display: "summarized" }
|
||||
: {}),
|
||||
...(adaptiveOpus ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
},
|
||||
@@ -872,9 +879,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: effort,
|
||||
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
|
||||
? { display: "summarized" }
|
||||
: {}),
|
||||
...(adaptiveOpus ? { display: "summarized" } : {}),
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -63,11 +63,11 @@ function decodeCredential(input: string) {
|
||||
Effect.match({
|
||||
onFailure: emptyCredential,
|
||||
onSuccess: (header) => {
|
||||
const parts = header.split(":")
|
||||
if (parts.length !== 2) return emptyCredential()
|
||||
const separator = header.indexOf(":")
|
||||
if (separator === -1) return emptyCredential()
|
||||
return {
|
||||
username: parts[0],
|
||||
password: Redacted.make(parts[1]),
|
||||
username: header.slice(0, separator),
|
||||
password: Redacted.make(header.slice(separator + 1)),
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
|
||||
return request.source instanceof Request ? request.source : undefined
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
|
||||
const len = request.headers["content-length"]
|
||||
return HttpBody.raw(webSource(request)?.body ?? null, {
|
||||
contentType: request.headers["content-type"],
|
||||
contentLength: len ? Number(len) : undefined,
|
||||
})
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined)
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
|
||||
+8
-5
@@ -159,14 +159,14 @@ function planWorkspaceRequest(
|
||||
|
||||
function planRequest(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
sessionWorkspaceID?: WorkspaceID,
|
||||
session?: Session.Info,
|
||||
): Effect.Effect<RequestPlan, never, Workspace.Service> {
|
||||
return Effect.gen(function* () {
|
||||
const url = requestURL(request)
|
||||
const envWorkspaceID = configuredWorkspaceID()
|
||||
const workspaceID = url.pathname.startsWith("/api/")
|
||||
? selectedV2WorkspaceID(url, sessionWorkspaceID)
|
||||
: selectedWorkspaceID(url, sessionWorkspaceID)
|
||||
? selectedV2WorkspaceID(url, session?.workspaceID)
|
||||
: selectedWorkspaceID(url, session?.workspaceID)
|
||||
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
|
||||
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
|
||||
|
||||
@@ -178,7 +178,10 @@ function planRequest(
|
||||
return yield* planWorkspaceRequest(request, url, workspace)
|
||||
}
|
||||
|
||||
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
|
||||
return RequestPlan.Local({
|
||||
directory: session?.directory || defaultDirectory(request, url),
|
||||
workspaceID: envWorkspaceID ?? workspaceID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -226,7 +229,7 @@ function routeHttpApiWorkspace<E>(
|
||||
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: undefined
|
||||
const plan = yield* planRequest(request, session?.workspaceID)
|
||||
const plan = yield* planRequest(request, session)
|
||||
return yield* routeWorkspace(client, effect, plan)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1155,6 +1155,17 @@ export function fromError(
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof ProviderError.ResponseStreamError:
|
||||
return new APIError(
|
||||
{
|
||||
message: e.message,
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: e.name,
|
||||
},
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const parsed = ProviderError.parseAPICallError({
|
||||
providerID: ctx.providerID,
|
||||
|
||||
@@ -351,7 +351,7 @@ export const layer: Layer.Layer<
|
||||
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const primary = yield* canonical(ctx.worktree)
|
||||
const primary = yield* canonical(ctx.project.worktree)
|
||||
const primaryName = pathSvc.basename(primary).toLowerCase()
|
||||
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { ACPNextEvent } from "@/acp-next/event"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
|
||||
const pollUntil = async (
|
||||
check: () => boolean | Promise<boolean>,
|
||||
message: string,
|
||||
opts?: { timeoutMs?: number; intervalMs?: number },
|
||||
) => {
|
||||
const started = Date.now()
|
||||
while (true) {
|
||||
if (await check()) return
|
||||
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
|
||||
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
|
||||
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
) {
|
||||
const replies: PermissionReplyParams[] = []
|
||||
const requests: RequestPermissionRequest[] = []
|
||||
const updates: SessionUpdateParams[] = []
|
||||
const session = makeSessionService()
|
||||
const sdk = {
|
||||
permission: {
|
||||
reply: (params: PermissionReplyParams) => {
|
||||
replies.push(params)
|
||||
return Promise.resolve({ data: true })
|
||||
},
|
||||
},
|
||||
session: {
|
||||
message: () => Promise.resolve({ data: undefined }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const connection = {
|
||||
requestPermission: (params: RequestPermissionRequest) => {
|
||||
requests.push(params)
|
||||
return requestPermission(params)
|
||||
},
|
||||
sessionUpdate: (params: SessionUpdateParams) => {
|
||||
updates.push(params)
|
||||
return Promise.resolve()
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
|
||||
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
|
||||
|
||||
return { connection, replies, requests, sdk, session, subscription, updates }
|
||||
}
|
||||
|
||||
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
|
||||
await Effect.runPromise(session.create({ id: sessionId, cwd }))
|
||||
}
|
||||
|
||||
async function createKnownTextPart(
|
||||
session: ACPNextSession.Interface,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
) {
|
||||
await Effect.runPromise(
|
||||
session.recordPartMetadata({
|
||||
sessionId,
|
||||
messageId,
|
||||
partId,
|
||||
partType: "text",
|
||||
role: "assistant",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function permissionAsked(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
input: {
|
||||
permission?: string
|
||||
metadata?: Record<string, unknown>
|
||||
tool?: { messageID: string; callID: string }
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id: `evt_${id}`,
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID,
|
||||
permission: input.permission ?? "bash",
|
||||
patterns: ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
always: [],
|
||||
...(input.tool ? { tool: input.tool } : {}),
|
||||
},
|
||||
} as PermissionEvent
|
||||
}
|
||||
|
||||
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
||||
return {
|
||||
id: `evt_${sessionID}_${messageID}_${partID}`,
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID,
|
||||
messageID,
|
||||
partID,
|
||||
field: "text",
|
||||
delta,
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
||||
return updates
|
||||
.filter((item) => item.sessionId === sessionId)
|
||||
.map((item) => item.update)
|
||||
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
|
||||
return update.sessionUpdate === "agent_message_chunk"
|
||||
})
|
||||
.map((update) => (update.content.type === "text" ? update.content.text : ""))
|
||||
.join("")
|
||||
}
|
||||
|
||||
describe("acp-next permissions", () => {
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_a",
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "bash",
|
||||
rawInput: { command: "printf hello" },
|
||||
kind: "execute",
|
||||
locations: [],
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("rejects non-selected outcomes", async () => {
|
||||
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
|
||||
|
||||
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
|
||||
})
|
||||
|
||||
it("rejects when requestPermission fails", async () => {
|
||||
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
|
||||
|
||||
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
|
||||
})
|
||||
|
||||
it("does not let a blocked session A permission block session B message updates", async () => {
|
||||
let releasePermission: (() => void) | undefined
|
||||
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
|
||||
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
})
|
||||
const harness = createHarness(() => blocked)
|
||||
await createSession(harness.session, "ses_a")
|
||||
await createSession(harness.session, "ses_b")
|
||||
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
|
||||
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
|
||||
|
||||
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
|
||||
|
||||
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
|
||||
expect(harness.replies).toHaveLength(0)
|
||||
|
||||
releasePermission?.()
|
||||
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
|
||||
})
|
||||
|
||||
it("serializes permission requests per session", async () => {
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const first = new Promise<RequestPermissionResponse>((resolve) => {
|
||||
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
})
|
||||
const harness = createHarness(() =>
|
||||
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
|
||||
)
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
|
||||
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
|
||||
|
||||
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
|
||||
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
||||
|
||||
releaseFirst?.()
|
||||
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
|
||||
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
|
||||
|
||||
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
|
||||
["perm_1", "once"],
|
||||
["perm_2", "always"],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ForkSessionResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
SessionNotification,
|
||||
ResumeSessionResponse,
|
||||
SessionConfigOption,
|
||||
SessionConfigSelectOption,
|
||||
@@ -14,6 +15,7 @@ import { Effect, ManagedRuntime } from "effect"
|
||||
import * as ACPNextService from "@/acp-next/service"
|
||||
import * as ACPNextError from "@/acp-next/error"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
import { UsageService } from "@/acp-next/usage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
@@ -141,10 +143,14 @@ const provider: Provider.Info = {
|
||||
|
||||
describe("ACP next service sessions", () => {
|
||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||
const updates: unknown[] = []
|
||||
const updates: SessionNotification[] = []
|
||||
const mcpAdds: string[] = []
|
||||
const aborts: string[] = []
|
||||
const forks: string[] = []
|
||||
const prompts: unknown[] = []
|
||||
const commands: unknown[] = []
|
||||
const summarizes: unknown[] = []
|
||||
const usageUpdates: string[] = []
|
||||
const sessions = Array.from({ length: 102 }, (_, index) => ({
|
||||
id: `ses_${index + 1}`,
|
||||
directory: index % 2 === 0 ? "/workspace" : "/other",
|
||||
@@ -184,6 +190,36 @@ describe("ACP next service sessions", () => {
|
||||
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
||||
}),
|
||||
messages: () => Promise.resolve({ data: messages }),
|
||||
prompt: (input: unknown) => {
|
||||
prompts.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
command: (input: unknown) => {
|
||||
commands.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 3,
|
||||
output: 4,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
summarize: (input: unknown) => {
|
||||
summarizes.push(input)
|
||||
return Promise.resolve({ data: true })
|
||||
},
|
||||
abort: (input: { sessionID: string }) => {
|
||||
aborts.push(input.sessionID)
|
||||
return Promise.resolve({ data: true })
|
||||
@@ -201,13 +237,33 @@ describe("ACP next service sessions", () => {
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const connection = {
|
||||
sessionUpdate: (update: unknown) => {
|
||||
sessionUpdate: (update: SessionNotification) => {
|
||||
updates.push(update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as Pick<AgentSideConnection, "sessionUpdate">
|
||||
const usage = UsageService.Service.of({
|
||||
buildUsage: UsageService.buildUsage,
|
||||
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||
totalSessionCost: UsageService.totalSessionCost,
|
||||
contextLimit: () => Effect.succeed(128000),
|
||||
sendUpdate: (input) =>
|
||||
Effect.sync(() => {
|
||||
usageUpdates.push(input.sessionID)
|
||||
}),
|
||||
})
|
||||
|
||||
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
|
||||
return {
|
||||
service: ACPNextService.make({ sdk, connection, usage }),
|
||||
updates,
|
||||
mcpAdds,
|
||||
aborts,
|
||||
forks,
|
||||
prompts,
|
||||
commands,
|
||||
summarizes,
|
||||
usageUpdates,
|
||||
}
|
||||
}
|
||||
|
||||
it("creates a backed session with config options and command update", async () => {
|
||||
@@ -267,6 +323,15 @@ describe("ACP next service sessions", () => {
|
||||
expect(second.sessions).toEqual(first.sessions)
|
||||
})
|
||||
|
||||
it("includes live ACP sessions before they appear in server-backed session list", async () => {
|
||||
const { service } = makeService()
|
||||
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
|
||||
|
||||
expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
|
||||
expect(listed.sessions[0]?.cwd).toBe("/workspace")
|
||||
})
|
||||
|
||||
it("lists all sessions with next cursor when the first page is full", async () => {
|
||||
const { service } = makeService()
|
||||
const first = await Effect.runPromise(service.listSessions({}))
|
||||
@@ -546,6 +611,46 @@ describe("ACP next service sessions", () => {
|
||||
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
|
||||
})
|
||||
|
||||
it("does not scan last-used sessions when resolving the new session default", async () => {
|
||||
const historyCalls: string[] = []
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||
skills: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
|
||||
list: () => {
|
||||
historyCalls.push("list")
|
||||
return Promise.resolve({ data: [{ id: "ses_recent" }] })
|
||||
},
|
||||
messages: () => {
|
||||
historyCalls.push("messages")
|
||||
return Promise.resolve({
|
||||
data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
|
||||
})
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
|
||||
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(result.sessionId).toBe("test-model")
|
||||
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
|
||||
expect(historyCalls).toEqual([])
|
||||
})
|
||||
|
||||
it("switches model and returns updated model and effort options", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
@@ -611,24 +716,35 @@ describe("ACP next service sessions", () => {
|
||||
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
|
||||
})
|
||||
|
||||
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
|
||||
let providersCalls = 0
|
||||
let commandCalls = 0
|
||||
it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
|
||||
const calls = {
|
||||
providers: 0,
|
||||
agents: 0,
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
mcpAdds: 0,
|
||||
}
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
providersCalls++
|
||||
calls.providers++
|
||||
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
|
||||
},
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||
skills: () => Promise.resolve({ data: [] }),
|
||||
agents: () => {
|
||||
calls.agents++
|
||||
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
|
||||
},
|
||||
skills: () => {
|
||||
calls.skills++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: () => {
|
||||
commandCalls++
|
||||
calls.commands++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
@@ -637,14 +753,16 @@ describe("ACP next service sessions", () => {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
add: () => {
|
||||
calls.mcpAdds++
|
||||
return Promise.resolve({ data: {} })
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
|
||||
|
||||
await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
@@ -654,11 +772,350 @@ describe("ACP next service sessions", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(providersCalls).toBe(1)
|
||||
expect(commandCalls).toBe(1)
|
||||
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
|
||||
})
|
||||
|
||||
it("switches model against the warm provider snapshot without refetching", async () => {
|
||||
const calls = {
|
||||
providers: 0,
|
||||
agents: 0,
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
}
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
calls.providers++
|
||||
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
|
||||
},
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => {
|
||||
calls.agents++
|
||||
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
|
||||
},
|
||||
skills: () => {
|
||||
calls.skills++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: () => {
|
||||
calls.commands++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const updated = await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
|
||||
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
|
||||
})
|
||||
|
||||
it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
|
||||
const calls = {
|
||||
providers: 0,
|
||||
config: 0,
|
||||
agents: 0,
|
||||
commands: 0,
|
||||
skills: 0,
|
||||
sessionList: 0,
|
||||
messages: 0,
|
||||
creates: 0,
|
||||
}
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => {
|
||||
calls.providers++
|
||||
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
|
||||
},
|
||||
get: () => {
|
||||
calls.config++
|
||||
return Promise.resolve({ data: {} })
|
||||
},
|
||||
},
|
||||
app: {
|
||||
agents: () => {
|
||||
calls.agents++
|
||||
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
|
||||
},
|
||||
skills: () => {
|
||||
calls.skills++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: () => {
|
||||
calls.commands++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: () => {
|
||||
calls.creates++
|
||||
return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
|
||||
},
|
||||
list: () => {
|
||||
calls.sessionList++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
messages: () => {
|
||||
calls.messages++
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
|
||||
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(first.sessionId).toBe("ses_warm_1")
|
||||
expect(second.sessionId).toBe("ses_warm_2")
|
||||
expect(calls).toEqual({
|
||||
providers: 1,
|
||||
config: 1,
|
||||
agents: 1,
|
||||
commands: 1,
|
||||
skills: 1,
|
||||
sessionList: 0,
|
||||
messages: 0,
|
||||
creates: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("normal text prompt sends model variant mode and converted parts", async () => {
|
||||
const { service, prompts, usageUpdates } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000001",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
sessionID: session.sessionId,
|
||||
model: { providerID, modelID },
|
||||
variant: "high",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
agent: "plan",
|
||||
directory: "/workspace",
|
||||
},
|
||||
])
|
||||
expect(result).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
thoughtTokens: 7,
|
||||
cachedReadTokens: 11,
|
||||
cachedWriteTokens: 13,
|
||||
totalTokens: 171,
|
||||
},
|
||||
userMessageId: "00000000-0000-4000-8000-000000000001",
|
||||
_meta: {},
|
||||
})
|
||||
expect(usageUpdates).toEqual([session.sessionId])
|
||||
})
|
||||
|
||||
it("prompt maps assistant and user audience annotations", async () => {
|
||||
const { service, prompts } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
await Effect.runPromise(
|
||||
service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "text", text: "user context", annotations: { audience: ["user"] } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prompts).toContainEqual({
|
||||
sessionID: session.sessionId,
|
||||
model: { providerID, modelID },
|
||||
variant: "default",
|
||||
parts: [
|
||||
{ type: "text", text: "assistant context", synthetic: true },
|
||||
{ type: "text", text: "user context", ignored: true },
|
||||
],
|
||||
agent: "build",
|
||||
directory: "/workspace",
|
||||
})
|
||||
})
|
||||
|
||||
it("prompt sends image and resource parts", async () => {
|
||||
const { service, prompts } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
await Effect.runPromise(
|
||||
service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///tmp/report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
blob: "JVBERg==",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect((prompts[0] as { parts?: unknown }).parts).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "data:image/png;base64,AAAA",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
url: "data:application/pdf;base64,JVBERg==",
|
||||
filename: "report.pdf",
|
||||
mime: "application/pdf",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("slash command prompt calls session command", async () => {
|
||||
const { service, prompts, commands } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
|
||||
)
|
||||
|
||||
expect(prompts).toEqual([])
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
sessionID: session.sessionId,
|
||||
command: "init",
|
||||
arguments: "now",
|
||||
model: "test/test-model",
|
||||
variant: "default",
|
||||
agent: "build",
|
||||
directory: "/workspace",
|
||||
},
|
||||
])
|
||||
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
|
||||
})
|
||||
|
||||
it("compact slash command calls summarize path", async () => {
|
||||
const { service, prompts, commands, summarizes } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
await Effect.runPromise(
|
||||
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
|
||||
)
|
||||
|
||||
expect(prompts).toEqual([])
|
||||
expect(commands).toEqual([])
|
||||
expect(summarizes).toEqual([
|
||||
{
|
||||
sessionID: session.sessionId,
|
||||
directory: "/workspace",
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("maps prompt auth failures to auth-required request errors", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const failing = ACPNextService.make({
|
||||
sdk: {
|
||||
config: {
|
||||
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||
skills: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
create: () => Promise.resolve({ data: { id: session.sessionId } }),
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
usage: UsageService.Service.of({
|
||||
buildUsage: UsageService.buildUsage,
|
||||
latestAssistantMessage: UsageService.latestAssistantMessage,
|
||||
totalSessionCost: UsageService.totalSessionCost,
|
||||
contextLimit: () => Effect.succeed(128000),
|
||||
sendUpdate: () => Effect.void,
|
||||
}),
|
||||
})
|
||||
await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const error = await Effect.runPromise(
|
||||
failing
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32000)
|
||||
})
|
||||
})
|
||||
|
||||
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
providerID: "test",
|
||||
modelID: "test-model",
|
||||
cost: 0,
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
function categories(result: NewSessionResponse | LoadSessionResponse) {
|
||||
return result.configOptions?.map((option) => option.category) ?? []
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
@@ -12,6 +13,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { AuthWellKnownTest } from "../fake/auth-well-known"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
import { SkillTest } from "../fake/skill"
|
||||
@@ -26,6 +28,8 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age
|
||||
const provider = ProviderTest.fake()
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(AuthWellKnownTest.empty),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type {
|
||||
AuthenticateResponse,
|
||||
CloseSessionResponse,
|
||||
InitializeResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
SessionNotification,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
|
||||
|
||||
describe("opencode acp-next (subprocess)", () => {
|
||||
cliIt.live(
|
||||
"responds to initialize behind OPENCODE_ACP_NEXT",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
|
||||
const initialized = expectOk(
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "terminal-auth": true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"authenticate succeeds for the advertised auth method and rejects unknown methods safely",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
|
||||
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
|
||||
const methodId = initialized.authMethods?.[0]?.id
|
||||
expect(methodId).toBe("opencode-login")
|
||||
|
||||
expectOk(yield* acp.request<AuthenticateResponse>("authenticate", { methodId }))
|
||||
|
||||
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
|
||||
expect(errorCode(rejected.error)).toBe(-32602)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"creates and loads sessions behind OPENCODE_ACP_NEXT",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
expect(typeof session.sessionId).toBe("string")
|
||||
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
|
||||
|
||||
const update = yield* acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) =>
|
||||
params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update",
|
||||
)
|
||||
expect(update.params?.sessionId).toBe(session.sessionId)
|
||||
|
||||
const loaded = expectOk(
|
||||
yield* acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||
|
||||
const prompt = yield* acp.request("session/prompt", {
|
||||
sessionId: "ses_missing",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
expect(errorCode(prompt.error)).toBe(-32601)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"switches model through config options behind OPENCODE_ACP_NEXT",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
|
||||
const updated = expectOk(
|
||||
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"switches effort through config options behind OPENCODE_ACP_NEXT",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
const effort = selectConfigOption(session.configOptions, "effort")
|
||||
expect(effort?.category).toBe("thought_level")
|
||||
const nextEffort = effort ? firstAlternateValue(effort) : undefined
|
||||
expect(nextEffort).toBe("high")
|
||||
|
||||
const updated = expectOk(
|
||||
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: nextEffort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"advertises and supports close behind OPENCODE_ACP_NEXT",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
|
||||
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"advertises and supports resume behind OPENCODE_ACP_NEXT",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
yield* opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
|
||||
const resumed = expectOk(
|
||||
yield* acp.request<ResumeSessionResponse>("session/resume", {
|
||||
cwd: home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"exits cleanly when flagged stdin is closed",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const exitedPromise = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
|
||||
return acp.exited
|
||||
}),
|
||||
)
|
||||
|
||||
const code = yield* Effect.promise(() => exitedPromise)
|
||||
expect(typeof code === "number" || code === null).toBe(true)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"default unflagged path still uses production ACP",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(yield* opencode.acp())
|
||||
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
|
||||
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
if (!("code" in error)) return undefined
|
||||
return typeof error.code === "number" ? error.code : undefined
|
||||
}
|
||||
|
||||
function verifierConfig(llmUrl: string) {
|
||||
const config = testProviderConfig(llmUrl)
|
||||
return {
|
||||
...config,
|
||||
model: "test/test-model",
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
models: {
|
||||
"test-model": {
|
||||
...config.provider.test.models["test-model"],
|
||||
variants: {
|
||||
low: {},
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
"second-model": {
|
||||
...config.provider.test.models["test-model"],
|
||||
id: "second-model",
|
||||
name: "Second Test Model",
|
||||
variants: {
|
||||
medium: {},
|
||||
max: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
|
||||
import {
|
||||
createAcpNextClient,
|
||||
expectAlternateValue,
|
||||
expectSelectOption,
|
||||
initialize,
|
||||
newSession,
|
||||
verifierConfig,
|
||||
} from "./helpers"
|
||||
|
||||
describe("opencode acp-next config option subprocess", () => {
|
||||
cliIt.live(
|
||||
'model option is listed with category "model"',
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model")
|
||||
|
||||
expect(model.category).toBe("model")
|
||||
expect(model.currentValue).toBe("test/test-model")
|
||||
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"model switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
const model = expectSelectOption(session.configOptions, "model")
|
||||
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
|
||||
expect(nextModel).toBe("test/second-model")
|
||||
|
||||
const updated = expectOk(
|
||||
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: nextModel,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
'effort option is listed with category "thought_level" when selected model supports variants',
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort")
|
||||
|
||||
expect(effort.category).toBe("thought_level")
|
||||
expect(effort.currentValue).toBe("low")
|
||||
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"effort switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort"))
|
||||
|
||||
const updated = expectOk(
|
||||
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: nextEffort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect } from "bun:test"
|
||||
import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import type { CliFixture } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
import {
|
||||
createAcpClient,
|
||||
expectOk,
|
||||
flattenSelectOptions,
|
||||
selectConfigOption,
|
||||
type AcpClient,
|
||||
} from "../acp/acp-test-client"
|
||||
|
||||
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
|
||||
return Effect.gen(function* () {
|
||||
return createAcpClient(
|
||||
yield* input.opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
...env,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function initialize(acp: AcpClient) {
|
||||
return Effect.gen(function* () {
|
||||
return expectOk(
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "terminal-auth": true } },
|
||||
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function newSession(acp: AcpClient, cwd: string) {
|
||||
return Effect.gen(function* () {
|
||||
return expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
export function verifierConfig(llmUrl: string, skills?: string) {
|
||||
const config = testProviderConfig(llmUrl)
|
||||
return {
|
||||
...config,
|
||||
model: "test/test-model",
|
||||
...(skills ? { skills: { paths: [skills] } } : {}),
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
models: {
|
||||
"test-model": {
|
||||
...config.provider.test.models["test-model"],
|
||||
variants: {
|
||||
low: {},
|
||||
high: {},
|
||||
},
|
||||
},
|
||||
"second-model": {
|
||||
...config.provider.test.models["test-model"],
|
||||
id: "second-model",
|
||||
name: "Second Test Model",
|
||||
variants: {
|
||||
medium: {},
|
||||
max: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function expectErrorCode(error: unknown, code: number) {
|
||||
if (!error || typeof error !== "object" || !("code" in error)) {
|
||||
expect(error).toEqual({ code })
|
||||
return
|
||||
}
|
||||
expect(error.code).toBe(code)
|
||||
}
|
||||
|
||||
export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
|
||||
const option = selectConfigOption(options, id)
|
||||
expect(option).toBeDefined()
|
||||
return option!
|
||||
}
|
||||
|
||||
export function expectAlternateValue(option: ReturnType<typeof expectSelectOption>) {
|
||||
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
|
||||
expect(value).toBeDefined()
|
||||
return value!
|
||||
}
|
||||
|
||||
export const verifierSkill = `---
|
||||
name: verifier-skill
|
||||
description: Verifier compatibility skill.
|
||||
---
|
||||
|
||||
# Verifier Skill
|
||||
`
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
|
||||
|
||||
describe("opencode acp-next initialize/auth subprocess", () => {
|
||||
cliIt.live(
|
||||
"initialize responds with capabilities",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"auth negotiation is explicit and safe",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient({ opencode })
|
||||
const initialized = yield* initialize(acp)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
|
||||
expect(yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" })).toMatchObject({
|
||||
result: {},
|
||||
})
|
||||
|
||||
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
|
||||
expectErrorCode(rejected.error, -32602)
|
||||
expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"initialize without terminal-auth metadata keeps auth command implicit",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient({ opencode })
|
||||
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
|
||||
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
|
||||
describe("opencode acp-next lifecycle subprocess", () => {
|
||||
cliIt.live(
|
||||
"stdin EOF exits cleanly",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
|
||||
acp.close()
|
||||
|
||||
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
|
||||
expect(code).toBe(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"close capability and close request",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
const initialized = yield* initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
|
||||
const session = yield* newSession(acp, home)
|
||||
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"loadSession capability and load request return session config options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
const initialized = yield* initialize(acp)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
const session = yield* newSession(acp, home)
|
||||
const loaded = expectOk(
|
||||
yield* acp.request<LoadSessionResponse>("session/load", {
|
||||
cwd: home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"list request includes a live ACP-created session",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
|
||||
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"resume capability advertisement",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
|
||||
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"resume request returns session config options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
const resumed = expectOk(
|
||||
yield* acp.request<ResumeSessionResponse>("session/resume", {
|
||||
cwd: home,
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk } from "../acp/acp-test-client"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
|
||||
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
|
||||
describe("opencode acp-next prompt content subprocess", () => {
|
||||
cliIt.live(
|
||||
"accepts embedded text resource image and file resource link prompt content",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
|
||||
yield* llm.text("embedded resource accepted")
|
||||
expectOk(
|
||||
yield* acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this embedded resource." },
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
yield* llm.text("image accepted")
|
||||
expectOk(
|
||||
yield* acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this image." },
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
data: tinyPng,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
yield* llm.text("file link accepted")
|
||||
const linked = expectOk(
|
||||
yield* acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this linked file." },
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: pathToFileURL(path.join(home, "README.md")).href,
|
||||
name: "README.md",
|
||||
mimeType: "text/markdown",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(linked.stopReason).toBe("end_turn")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
function promptContentConfig(llmUrl: string) {
|
||||
const config = verifierConfig(llmUrl)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(config.provider.test.models).map(([id, model]) => [
|
||||
id,
|
||||
{
|
||||
...model,
|
||||
attachment: true,
|
||||
reasoning: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
|
||||
|
||||
describe("opencode acp-next skills subprocess", () => {
|
||||
cliIt.live(
|
||||
"skill slash command appears through available_commands_update",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const skills = path.join(home, "skills")
|
||||
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
|
||||
const acp = yield* createAcpNextClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
|
||||
)
|
||||
yield* initialize(acp)
|
||||
const session = yield* newSession(acp, home)
|
||||
|
||||
const update = yield* acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) =>
|
||||
params.sessionId === session.sessionId &&
|
||||
params.update.sessionUpdate === "available_commands_update" &&
|
||||
params.update.availableCommands.some(
|
||||
(command) => command.name === "verifier-skill" && command.description.length > 0,
|
||||
),
|
||||
)
|
||||
|
||||
expect(update.params?.sessionId).toBe(session.sessionId)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -40,7 +40,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"first session timing diagnostic stays bounded and returns model options",
|
||||
"first session returns model options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -50,7 +50,6 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const started = Date.now()
|
||||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
@@ -62,9 +61,6 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
const durationMs = Date.now() - started
|
||||
expect(durationMs).toBeLessThan(15_000)
|
||||
|
||||
const model = selectConfigOption(session.configOptions, "model")
|
||||
expect(model?.category).toBe("model")
|
||||
expect(model?.currentValue).toBe("test/test-model")
|
||||
@@ -74,7 +70,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"warm newSession timing diagnostic stays bounded",
|
||||
"newSession can be called repeatedly",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -87,22 +83,19 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
|
||||
|
||||
const started = Date.now()
|
||||
const session = expectOk(
|
||||
yield* acp.request<NewSessionResponse>("session/new", {
|
||||
cwd: home,
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
const durationMs = Date.now() - started
|
||||
expect(durationMs).toBeLessThan(15_000)
|
||||
expect(session.sessionId).toBeTruthy()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"model switch timing diagnostic updates currentValue",
|
||||
"model switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = createAcpClient(
|
||||
@@ -121,7 +114,6 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
: undefined
|
||||
expect(nextModel).toBe("test/second-model")
|
||||
|
||||
const started = Date.now()
|
||||
const updated = expectOk(
|
||||
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
@@ -129,9 +121,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
value: nextModel,
|
||||
}),
|
||||
)
|
||||
const durationMs = Date.now() - started
|
||||
|
||||
expect(durationMs).toBeLessThan(15_000)
|
||||
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
|
||||
}),
|
||||
60_000,
|
||||
@@ -189,7 +179,7 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"skill slash command timing diagnostic appears through available_commands_update",
|
||||
"skill slash command appears through available_commands_update",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const skills = path.join(home, "skills")
|
||||
@@ -214,20 +204,6 @@ describe("opencode acp verifier compatibility baseline", () => {
|
||||
)
|
||||
|
||||
expect(update.params?.sessionId).toBe(session.sessionId)
|
||||
|
||||
const secondSession = expectOk(
|
||||
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
|
||||
)
|
||||
const started = Date.now()
|
||||
yield* acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) =>
|
||||
params.sessionId === secondSession.sessionId &&
|
||||
params.update.sessionUpdate === "available_commands_update" &&
|
||||
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
|
||||
)
|
||||
const durationMs = Date.now() - started
|
||||
expect(durationMs).toBeLessThan(15_000)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
sessionID: "ses_test",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "model" },
|
||||
}
|
||||
}
|
||||
|
||||
function text(messageID: string, text: string): Part {
|
||||
return {
|
||||
id: `part_${messageID}`,
|
||||
sessionID: "ses_test",
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
describe("TUI optimistic prompt sync", () => {
|
||||
test("keeps an optimistic message while session sync has not fetched it yet", () => {
|
||||
const merged = mergeFetchedMessages({
|
||||
currentMessages: [user("msg_2")],
|
||||
currentParts: { msg_2: [text("msg_2", "optimistic")] },
|
||||
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
||||
optimisticMessages: new Set(["msg_2"]),
|
||||
})
|
||||
|
||||
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
|
||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
||||
expect(merged.resolved.has("msg_2")).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves optimistic parts when sync fetches the message before its parts", () => {
|
||||
const merged = mergeFetchedMessages({
|
||||
currentMessages: [user("msg_1")],
|
||||
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
||||
fetched: [{ info: user("msg_1"), parts: [] }],
|
||||
optimisticMessages: new Set(["msg_1"]),
|
||||
})
|
||||
|
||||
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
|
||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
|
||||
expect(merged.resolved.has("msg_1")).toBe(false)
|
||||
})
|
||||
|
||||
test("replaces optimistic parts once real fetched parts arrive", () => {
|
||||
const merged = mergeFetchedMessages({
|
||||
currentMessages: [user("msg_1")],
|
||||
currentParts: { msg_1: [text("msg_1", "optimistic")] },
|
||||
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
|
||||
optimisticMessages: new Set(["msg_1"]),
|
||||
})
|
||||
|
||||
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
|
||||
expect(merged.resolved.has("msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("strips file URLs from optimistic render parts", () => {
|
||||
const parts = optimisticParts({
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_1",
|
||||
parts: [
|
||||
{
|
||||
id: "part_file",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "image.png",
|
||||
url: "data:image/png;base64,large",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(parts).toEqual([
|
||||
{
|
||||
id: "part_file",
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_1",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "image.png",
|
||||
url: "",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
No LSP server available for this file type.
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
|
||||
"
|
||||
|
||||
# List files
|
||||
|
||||
$ ls
|
||||
|
||||
file.ts
|
||||
|
||||
|
||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createSignal, For } from "solid-js"
|
||||
import { testRender } from "@opentui/solid"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
testSetup?.renderer.destroy()
|
||||
testSetup = undefined
|
||||
})
|
||||
|
||||
type ToolFixture = { icon: string; label: string; error?: string }
|
||||
|
||||
const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
const tools: readonly ToolFixture[] = [
|
||||
{
|
||||
icon: "✱",
|
||||
label:
|
||||
'Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/opencode/src (151 matches)',
|
||||
},
|
||||
{
|
||||
icon: "✱",
|
||||
label: 'Glob "**/*db*" in packages/opencode (6 matches)',
|
||||
},
|
||||
{
|
||||
icon: "→",
|
||||
label: "Read packages/opencode/src/storage/db.ts [offset=1, limit=130]",
|
||||
},
|
||||
{
|
||||
icon: "→",
|
||||
label: "Read packages/opencode/src/index.ts [offset=1, limit=100]",
|
||||
error: "No LSP server available for this file type.",
|
||||
},
|
||||
{
|
||||
icon: "✱",
|
||||
label:
|
||||
'Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.Path\\.data|data =" in packages/opencode/src (115 matches)',
|
||||
},
|
||||
] as const
|
||||
|
||||
function InlineToolRow(props: { item: ToolFixture; errorExpanded?: boolean }) {
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
|
||||
return (
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
renderBefore={function () {
|
||||
const parent = this.parent
|
||||
if (!parent) return
|
||||
const previous = parent.getChildren()[parent.getChildren().indexOf(this) - 1]
|
||||
setMargin(previous?.id.startsWith("text-") || previous?.id.startsWith("tool-block-") ? 1 : 0)
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<text width={INLINE_TOOL_ICON_WIDTH}>{props.item.icon}</text>
|
||||
<text flexGrow={1}>{props.item.label}</text>
|
||||
</box>
|
||||
{props.item.error && props.errorExpanded && (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text>{props.item.error}</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellOutput() {
|
||||
return (
|
||||
<box id="tool-block-shell" marginTop={1} paddingTop={1} paddingBottom={1} paddingLeft={2} gap={1}>
|
||||
<text paddingLeft={3}># List files</text>
|
||||
<box gap={1}>
|
||||
<text>$ ls</text>
|
||||
<text>file.ts</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Fixture(props: { errorExpanded?: boolean; shellOutput?: boolean }) {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<box flexDirection="column">
|
||||
{props.shellOutput && <ShellOutput />}
|
||||
<For each={tools}>{(item) => <InlineToolRow item={item} errorExpanded={props.errorExpanded} />}</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
describe("TUI inline tool wrapping", () => {
|
||||
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
|
||||
testSetup = await testRender(() => <Fixture />, { width: 72, height: 12 })
|
||||
await testSetup.renderOnce()
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(
|
||||
testSetup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trimEnd(),
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("snapshots expanded tool errors under the tool text", async () => {
|
||||
testSetup = await testRender(() => <Fixture errorExpanded />, { width: 72, height: 12 })
|
||||
await testSetup.renderOnce()
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(
|
||||
testSetup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trimEnd(),
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("keeps separation after a shell output block", async () => {
|
||||
testSetup = await testRender(() => <Fixture shellOutput />, { width: 72, height: 16 })
|
||||
await testSetup.renderOnce()
|
||||
await testSetup.renderOnce()
|
||||
|
||||
expect(
|
||||
testSetup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trimEnd(),
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user