Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa5239785 | ||
|
|
e0d71f124e | ||
|
|
1c33b866ba | ||
|
|
5e650fd9e2 | ||
|
|
76275fc3ab | ||
|
|
6c3b28db64 | ||
|
|
2fe9d94470 | ||
|
|
219b473e66 | ||
|
|
7c1b30291c | ||
|
|
47e0e2342c | ||
|
|
bf4c107829 | ||
|
|
9afbdc102c | ||
|
|
370770122c | ||
|
|
143817d44e | ||
|
|
c60862fc9e | ||
|
|
bee5f919fc | ||
|
|
cefa7f04c6 | ||
|
|
03e20e6ac1 | ||
|
|
c5deeee8c7 | ||
|
|
8b1f0e2d90 | ||
|
|
9bf2dfea35 | ||
|
|
33bb847a1d | ||
|
|
bfffc3c2c6 | ||
|
|
b28956f0db | ||
|
|
d82bc3a421 | ||
|
|
06afd33291 | ||
|
|
305460b25f | ||
|
|
8c0205a84a | ||
|
|
378c05f202 | ||
|
|
cc7acd90ab | ||
|
|
a200f6fb8b | ||
|
|
2b1696f1d1 | ||
|
|
8ab17f5ce0 | ||
|
|
6ce481e95b | ||
|
|
7341718f92 | ||
|
|
ef90b93205 | ||
|
|
3f7df08be9 | ||
|
|
ef6c26c730 | ||
|
|
8b3b608ba9 | ||
|
|
97918500d4 | ||
|
|
e2c0803962 | ||
|
|
f418fd5632 | ||
|
|
675a46e23e | ||
|
|
150ab07a83 | ||
|
|
6b20838981 | ||
|
|
c8af8f96ce | ||
|
|
5011465c81 | ||
|
|
f6cc228684 | ||
|
|
9f4b73b6a3 | ||
|
|
bd29004831 | ||
|
|
8aa0f9fe95 | ||
|
|
c802695ee9 | ||
|
|
225a769411 | ||
|
|
0e20382396 | ||
|
|
509bc11f81 | ||
|
|
f24207844f | ||
|
|
1ca257e356 | ||
|
|
d4cfbd020d | ||
|
|
581d5208ca | ||
|
|
a427a28fa9 | ||
|
|
0beaf04df5 |
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...options.headers,
|
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...options.headers,
|
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+11
-2
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"suspicious": "warn"
|
"suspicious": "warn"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"typescript/no-base-to-string": "warn",
|
||||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||||
"require-yield": "off",
|
"require-yield": "off",
|
||||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||||
@@ -33,10 +37,15 @@
|
|||||||
"no-new": "off",
|
"no-new": "off",
|
||||||
|
|
||||||
// Type-aware: catch unhandled promises
|
// Type-aware: catch unhandled promises
|
||||||
"typescript/no-floating-promises": "warn"
|
"typescript/no-floating-promises": "warn",
|
||||||
|
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||||
|
"typescript/no-misused-spread": "warn"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"typeAware": true
|
"typeAware": true
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,15 +322,15 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
"@ai-sdk/amazon-bedrock": "4.0.94",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.70",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
"@ai-sdk/gateway": "3.0.97",
|
"@ai-sdk/gateway": "3.0.102",
|
||||||
"@ai-sdk/google": "3.0.63",
|
"@ai-sdk/google": "3.0.63",
|
||||||
"@ai-sdk/google-vertex": "4.0.109",
|
"@ai-sdk/google-vertex": "4.0.111",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.53",
|
||||||
@@ -516,6 +516,7 @@
|
|||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@npmcli/arborist": "catalog:",
|
"@npmcli/arborist": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"glob": "13.0.5",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
@@ -523,7 +524,9 @@
|
|||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -735,7 +738,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||||
|
|
||||||
@@ -755,11 +758,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
|
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
|
||||||
|
|
||||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.97", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="],
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
|
||||||
|
|
||||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.109", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/google": "3.0.63", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QzQ+DgOoSYlkU4mK0H+iaCaW1bl5zOimH9X2E2oylcVyUtAdCuduQ959Uw1ygW3l09J2K/ceEDtK8OUPHyOA7g=="],
|
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
|
||||||
|
|
||||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||||
|
|
||||||
@@ -5149,7 +5152,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -5163,7 +5170,9 @@
|
|||||||
|
|
||||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
|
||||||
|
|
||||||
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
@@ -5681,6 +5690,8 @@
|
|||||||
|
|
||||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
|
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
|
||||||
@@ -5897,7 +5908,7 @@
|
|||||||
|
|
||||||
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=",
|
"x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
|
||||||
"aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=",
|
"aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
|
||||||
"aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=",
|
"aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
|
||||||
"x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU="
|
"x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
|
|||||||
--filter './packages/opencode' \
|
--filter './packages/opencode' \
|
||||||
--filter './packages/desktop' \
|
--filter './packages/desktop' \
|
||||||
--filter './packages/app' \
|
--filter './packages/app' \
|
||||||
|
--filter './packages/shared' \
|
||||||
--frozen-lockfile \
|
--frozen-lockfile \
|
||||||
--ignore-scripts \
|
--ignore-scripts \
|
||||||
--no-progress
|
--no-progress
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) {
|
|||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
<Suspense fallback={<Loading />}>
|
{/*<Suspense fallback={<Loading />}>*/}
|
||||||
{props.appChildren}
|
{props.appChildren}
|
||||||
{props.children}
|
{props.children}
|
||||||
</Suspense>
|
{/*</Suspense>*/}
|
||||||
</AppShellProviders>
|
</AppShellProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,14 +184,22 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Suspense
|
||||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
|
||||||
fallback={
|
fallback={
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
<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" />
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/*<Show
|
||||||
|
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
>*/}
|
||||||
|
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
|
||||||
<Show
|
<Show
|
||||||
when={startupHealthCheck()}
|
when={startupHealthCheck()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -209,7 +217,8 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
{/*</Show>*/}
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
|||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap"
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
class?: string
|
class?: string
|
||||||
@@ -100,6 +102,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
|
|||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
@@ -1249,6 +1252,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory))
|
||||||
|
const agentsLoading = () => agentsQuery.isLoading
|
||||||
|
|
||||||
|
const globalProvidersQuery = useQuery(() => loadProvidersQuery(null))
|
||||||
|
const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory))
|
||||||
|
|
||||||
|
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
@@ -1444,53 +1455,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
<div class="size-4 shrink-0" />
|
<div class="size-4 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
|
||||||
<div data-component="prompt-agent-control">
|
<Show when={!agentsLoading()}>
|
||||||
<TooltipKeybind
|
<div data-component="prompt-agent-control">
|
||||||
placement="top"
|
<TooltipKeybind
|
||||||
gutter={4}
|
placement="top"
|
||||||
title={language.t("command.agent.cycle")}
|
gutter={4}
|
||||||
keybind={command.keybind("agent.cycle")}
|
title={language.t("command.agent.cycle")}
|
||||||
>
|
keybind={command.keybind("agent.cycle")}
|
||||||
<Select
|
>
|
||||||
size="normal"
|
<Select
|
||||||
options={agentNames()}
|
size="normal"
|
||||||
current={local.agent.current()?.name ?? ""}
|
options={agentNames()}
|
||||||
onSelect={(value) => {
|
current={local.agent.current()?.name ?? ""}
|
||||||
local.agent.set(value)
|
onSelect={(value) => {
|
||||||
restoreFocus()
|
local.agent.set(value)
|
||||||
}}
|
restoreFocus()
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
}}
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
triggerStyle={control()}
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
triggerProps={{ "data-action": "prompt-agent" }}
|
triggerStyle={control()}
|
||||||
variant="ghost"
|
triggerProps={{ "data-action": "prompt-agent" }}
|
||||||
/>
|
variant="ghost"
|
||||||
</TooltipKeybind>
|
/>
|
||||||
</div>
|
</TooltipKeybind>
|
||||||
<Show when={store.mode !== "shell"}>
|
</div>
|
||||||
<div data-component="prompt-model-control">
|
</Show>
|
||||||
<Show
|
<Show when={!providersLoading()}>
|
||||||
when={providers.paid().length > 0}
|
<Show when={store.mode !== "shell"}>
|
||||||
fallback={
|
<div data-component="prompt-model-control">
|
||||||
|
<Show
|
||||||
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<Button
|
<ModelSelectorPopover
|
||||||
data-action="prompt-model"
|
model={local.model}
|
||||||
as="div"
|
triggerAs={Button}
|
||||||
variant="ghost"
|
triggerProps={{
|
||||||
size="normal"
|
variant: "ghost",
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
size: "normal",
|
||||||
style={control()}
|
style: control(),
|
||||||
onClick={() => {
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
"data-action": "prompt-model",
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
|
onClose={restoreFocus}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1503,67 +1550,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
|
<div data-component="prompt-variant-control">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.variant.cycle")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<Select
|
||||||
model={local.model}
|
size="normal"
|
||||||
triggerAs={Button}
|
options={variants()}
|
||||||
triggerProps={{
|
current={local.model.variant.current() ?? "default"}
|
||||||
variant: "ghost",
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
size: "normal",
|
onSelect={(value) => {
|
||||||
style: control(),
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
restoreFocus()
|
||||||
"data-action": "prompt-model",
|
|
||||||
}}
|
}}
|
||||||
onClose={restoreFocus}
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
>
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
triggerStyle={control()}
|
||||||
<ProviderIcon
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
id={local.model.current()?.provider?.id ?? ""}
|
variant="ghost"
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
/>
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
<div data-component="prompt-variant-control">
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={variants()}
|
|
||||||
current={local.model.variant.current() ?? "default"}
|
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onSelect={(value) => {
|
|
||||||
local.model.variant.set(value === "default" ? undefined : value)
|
|
||||||
restoreFocus()
|
|
||||||
}}
|
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={control()}
|
|
||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
||||||
let ws: WebSocket | undefined
|
let ws: WebSocket | undefined
|
||||||
let term: Term | undefined
|
let term: Term | undefined
|
||||||
let ghostty: Ghostty
|
let _ghostty: Ghostty
|
||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
@@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ghostty = g
|
_ghostty = g
|
||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data, done) =>
|
output = terminalWriter((data, done) =>
|
||||||
t.write(data, () => {
|
t.write(data, () => {
|
||||||
|
|||||||
@@ -252,41 +252,48 @@ export function Titlebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={hasProjects()}>
|
<div
|
||||||
<div
|
class="flex items-center shrink-0"
|
||||||
class="flex items-center gap-0 transition-transform"
|
classList={{
|
||||||
classList={{
|
"translate-x-0": !layout.sidebar.opened(),
|
||||||
"translate-x-0": !layout.sidebar.opened(),
|
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
"duration-180 ease-in": layout.sidebar.opened(),
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Show when={hasProjects()}>
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
<div class="flex items-center gap-0 transition-transform">
|
||||||
<Button
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||||
variant="ghost"
|
<Button
|
||||||
icon="chevron-left"
|
variant="ghost"
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
icon="chevron-left"
|
||||||
disabled={!canBack()}
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
onClick={back}
|
disabled={!canBack()}
|
||||||
aria-label={language.t("common.goBack")}
|
onClick={back}
|
||||||
/>
|
aria-label={language.t("common.goBack")}
|
||||||
</Tooltip>
|
/>
|
||||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
</Tooltip>
|
||||||
<Button
|
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||||
variant="ghost"
|
<Button
|
||||||
icon="chevron-right"
|
variant="ghost"
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
icon="chevron-right"
|
||||||
disabled={!canForward()}
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
onClick={forward}
|
disabled={!canForward()}
|
||||||
aria-label={language.t("common.goForward")}
|
onClick={forward}
|
||||||
/>
|
aria-label={language.t("common.goForward")}
|
||||||
</Tooltip>
|
/>
|
||||||
</div>
|
</Tooltip>
|
||||||
</Show>
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
|
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||||
|
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
|
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types"
|
|||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { sanitizeProject } from "./global-sync/utils"
|
import { sanitizeProject } from "./global-sync/utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -41,6 +42,9 @@ type GlobalStore = {
|
|||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loadSessionsQuery = (directory: string) =>
|
||||||
|
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -67,6 +71,7 @@ function createGlobalSync() {
|
|||||||
config: {},
|
config: {},
|
||||||
reload: undefined,
|
reload: undefined,
|
||||||
})
|
})
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
let projectWritten = false
|
let projectWritten = false
|
||||||
@@ -198,43 +203,50 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||||
const promise = loadRootSessionsWithFallback({
|
const promise = queryClient
|
||||||
directory,
|
.ensureQueryData({
|
||||||
limit,
|
...loadSessionsQuery(directory),
|
||||||
list: (query) => globalSDK.client.session.list(query),
|
queryFn: () =>
|
||||||
})
|
loadRootSessionsWithFallback({
|
||||||
.then((x) => {
|
directory,
|
||||||
const nonArchived = (x.data ?? [])
|
limit,
|
||||||
.filter((s) => !!s?.id)
|
list: (query) => globalSDK.client.session.list(query),
|
||||||
.filter((s) => !s.time?.archived)
|
})
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
.then((x) => {
|
||||||
const limit = store.limit
|
const nonArchived = (x.data ?? [])
|
||||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
.filter((s) => !!s?.id)
|
||||||
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
.filter((s) => !s.time?.archived)
|
||||||
limit,
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
permission: store.permission,
|
const limit = store.limit
|
||||||
})
|
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||||
setStore(
|
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||||
"sessionTotal",
|
limit,
|
||||||
estimateRootSessionTotal({
|
permission: store.permission,
|
||||||
count: nonArchived.length,
|
})
|
||||||
limit: x.limit,
|
setStore(
|
||||||
limited: x.limited,
|
"sessionTotal",
|
||||||
}),
|
estimateRootSessionTotal({
|
||||||
)
|
count: nonArchived.length,
|
||||||
setStore("session", reconcile(sessions, { key: "id" }))
|
limit: x.limit,
|
||||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
limited: x.limited,
|
||||||
sessionMeta.set(directory, { limit })
|
}),
|
||||||
})
|
)
|
||||||
.catch((err) => {
|
setStore("session", reconcile(sessions, { key: "id" }))
|
||||||
console.error("Failed to load sessions", err)
|
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||||
const project = getFilename(directory)
|
sessionMeta.set(directory, { limit })
|
||||||
showToast({
|
})
|
||||||
variant: "error",
|
.catch((err) => {
|
||||||
title: language.t("toast.session.listFailed.title", { project }),
|
console.error("Failed to load sessions", err)
|
||||||
description: formatServerError(err, language.t),
|
const project = getFilename(directory)
|
||||||
})
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.session.listFailed.title", { project }),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(() => null),
|
||||||
})
|
})
|
||||||
|
.then(() => {})
|
||||||
|
|
||||||
sessionLoads.set(directory, promise)
|
sessionLoads.set(directory, promise)
|
||||||
void promise.finally(() => {
|
void promise.finally(() => {
|
||||||
@@ -250,8 +262,9 @@ function createGlobalSync() {
|
|||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
children.pin(directory)
|
children.pin(directory)
|
||||||
const promise = (async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
|
child[1]("bootstrapPromise", promise!)
|
||||||
const cache = children.vcsCache.get(directory)
|
const cache = children.vcsCache.get(directory)
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
@@ -269,8 +282,9 @@ function createGlobalSync() {
|
|||||||
vcsCache: cache,
|
vcsCache: cache,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
|
queryClient,
|
||||||
})
|
})
|
||||||
})()
|
})
|
||||||
|
|
||||||
booting.set(directory, promise)
|
booting.set(directory, promise)
|
||||||
void promise.finally(() => {
|
void promise.finally(() => {
|
||||||
@@ -346,6 +360,7 @@ function createGlobalSync() {
|
|||||||
translate: language.t,
|
translate: language.t,
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
setGlobalStore: setBootStore,
|
setGlobalStore: setBootStore,
|
||||||
|
queryClient,
|
||||||
})
|
})
|
||||||
bootedAt = Date.now()
|
bootedAt = Date.now()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
|||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
|
||||||
|
import { loadSessionsQuery } from "../global-sync"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -71,6 +73,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const fast = [
|
const fast = [
|
||||||
() =>
|
() =>
|
||||||
@@ -80,11 +83,16 @@ export async function bootstrapGlobal(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
input.queryClient.fetchQuery({
|
||||||
input.globalSDK.provider.list().then((x) => {
|
...loadProvidersQuery(null),
|
||||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
queryFn: () =>
|
||||||
}),
|
retry(() =>
|
||||||
),
|
input.globalSDK.provider.list().then((x) => {
|
||||||
|
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const slow = [
|
const slow = [
|
||||||
@@ -172,6 +180,12 @@ function warmSessions(input: {
|
|||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loadProvidersQuery = (directory: string | null) =>
|
||||||
|
queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
|
||||||
|
|
||||||
|
export const loadAgentsQuery = (directory: string | null) =>
|
||||||
|
queryOptions<null>({ queryKey: [directory, "agents"], queryFn: skipToken })
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
@@ -186,6 +200,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
project: Project[]
|
project: Project[]
|
||||||
provider: ProviderListResponse
|
provider: ProviderListResponse
|
||||||
}
|
}
|
||||||
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
@@ -207,97 +222,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.setStore("lsp", [])
|
input.setStore("lsp", [])
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const fast = [
|
const fast = [() => Promise.resolve(input.loadSessions(input.directory))]
|
||||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
|
||||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
|
||||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
|
||||||
]
|
|
||||||
|
|
||||||
const slow = [
|
|
||||||
() =>
|
|
||||||
seededProject
|
|
||||||
? Promise.resolve()
|
|
||||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
|
||||||
() =>
|
|
||||||
seededPath
|
|
||||||
? Promise.resolve()
|
|
||||||
: retry(() =>
|
|
||||||
input.sdk.path.get().then((x) => {
|
|
||||||
input.setStore("path", x.data!)
|
|
||||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
|
||||||
if (next) input.setStore("project", next)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.sdk.vcs.get().then((x) => {
|
|
||||||
const next = x.data ?? input.store.vcs
|
|
||||||
input.setStore("vcs", next)
|
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.sdk.permission.list().then((x) => {
|
|
||||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
|
||||||
const grouped = groupBySession(
|
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
|
||||||
)
|
|
||||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.permission)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("permission", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"permission",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.sdk.question.list().then((x) => {
|
|
||||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
|
||||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.question)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("question", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"question",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.sdk.mcp.status().then((x) => {
|
|
||||||
input.setStore("mcp", x.data!)
|
|
||||||
input.setStore("mcp_ready", true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const errs = errors(await runAll(fast))
|
const errs = errors(await runAll(fast))
|
||||||
if (errs.length > 0) {
|
if (errs.length > 0) {
|
||||||
@@ -310,36 +235,138 @@ export async function bootstrapDirectory(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForPaint()
|
;(async () => {
|
||||||
const slowErrs = errors(await runAll(slow))
|
const slow = [
|
||||||
if (slowErrs.length > 0) {
|
() =>
|
||||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
input.queryClient.ensureQueryData({
|
||||||
const project = getFilename(input.directory)
|
...loadAgentsQuery(input.directory),
|
||||||
showToast({
|
queryFn: () =>
|
||||||
variant: "error",
|
retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then(
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
() => null,
|
||||||
description: formatServerError(slowErrs[0], input.translate),
|
),
|
||||||
})
|
}),
|
||||||
}
|
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||||
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
|
() =>
|
||||||
|
seededProject
|
||||||
|
? Promise.resolve()
|
||||||
|
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
() =>
|
||||||
|
seededPath
|
||||||
|
? Promise.resolve()
|
||||||
|
: retry(() =>
|
||||||
|
input.sdk.path.get().then((x) => {
|
||||||
|
input.setStore("path", x.data!)
|
||||||
|
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.vcs.get().then((x) => {
|
||||||
|
const next = x.data ?? input.store.vcs
|
||||||
|
input.setStore("vcs", next)
|
||||||
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.permission.list().then((x) => {
|
||||||
|
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||||
|
const grouped = groupBySession(
|
||||||
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||||
|
)
|
||||||
|
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.question.list().then((x) => {
|
||||||
|
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||||
|
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||||
|
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.mcp.status().then((x) => {
|
||||||
|
input.setStore("mcp", x.data!)
|
||||||
|
input.setStore("mcp_ready", true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
await waitForPaint()
|
||||||
|
const slowErrs = errors(await runAll(slow))
|
||||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
if (slowErrs.length > 0) {
|
||||||
providerRev.set(input.directory, rev)
|
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||||
void retry(() => input.sdk.provider.list())
|
|
||||||
.then((x) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) return
|
|
||||||
input.setStore("provider", normalizeProviderList(x.data!))
|
|
||||||
input.setStore("provider_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) return
|
|
||||||
console.error("Failed to refresh provider list", err)
|
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
description: formatServerError(err, input.translate),
|
description: formatServerError(slowErrs[0], input.translate),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
|
|
||||||
|
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||||
|
providerRev.set(input.directory, rev)
|
||||||
|
void input.queryClient.ensureQueryData({
|
||||||
|
...loadSessionsQuery(input.directory),
|
||||||
|
queryFn: () =>
|
||||||
|
retry(() => input.sdk.provider.list())
|
||||||
|
.then((x) => {
|
||||||
|
if (providerRev.get(input.directory) !== rev) return
|
||||||
|
input.setStore("provider", normalizeProviderList(x.data!))
|
||||||
|
input.setStore("provider_ready", true)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
|
||||||
|
const project = getFilename(input.directory)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
|
description: formatServerError(err, input.translate),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(() => null),
|
||||||
})
|
})
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function createChildStoreManager(input: {
|
|||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
part: {},
|
part: {},
|
||||||
|
bootstrapPromise: Promise.resolve(),
|
||||||
})
|
})
|
||||||
children[directory] = child
|
children[directory] = child
|
||||||
disposers.set(directory, dispose)
|
disposers.set(directory, dispose)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export type State = {
|
|||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
|
bootstrapPromise: Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsCache = {
|
export type VcsCache = {
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@ import "solid-js"
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||||
|
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { dict as en } from "./en"
|
|
||||||
|
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"command.category.suggested": "추천",
|
"command.category.suggested": "추천",
|
||||||
"command.category.view": "보기",
|
"command.category.view": "보기",
|
||||||
|
|||||||
@@ -132,9 +132,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (!slug) return { slug, dir: "" }
|
if (!slug) return { slug, dir: "" }
|
||||||
const dir = decode64(slug)
|
const dir = decode64(slug)
|
||||||
if (!dir) return { slug, dir: "" }
|
if (!dir) return { slug, dir: "" }
|
||||||
|
const store = globalSync.peek(dir, { bootstrap: false })
|
||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
store,
|
||||||
|
dir: store[0].path.directory || dir,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||||
@@ -2353,8 +2355,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [loading] = createResource(
|
||||||
|
() => route()?.store?.[0]?.bootstrapPromise,
|
||||||
|
(p) => p,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
|
{(autoselecting(), loading()) ?? ""}
|
||||||
<Titlebar />
|
<Titlebar />
|
||||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
import { sortedRootSessions, workspaceKey } from "./helpers"
|
import { sortedRootSessions, workspaceKey } from "./helpers"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
type InlineEditorComponent = (props: {
|
||||||
id: string
|
id: string
|
||||||
@@ -454,7 +455,8 @@ export const LocalWorkspace = (props: {
|
|||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const loading = createMemo(() => !booted() && count() === 0)
|
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
||||||
|
const loading = createMemo(() => query.isPending && count() === 0)
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
@@ -471,7 +473,7 @@ export const LocalWorkspace = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={() => false}
|
showNew={() => false}
|
||||||
loading={loading}
|
loading={() => query.isLoading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
untrack,
|
untrack,
|
||||||
|
createResource,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
@@ -432,7 +433,6 @@ export default function Page() {
|
|||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
|
||||||
const canReview = createMemo(() => !!sync.project)
|
const canReview = createMemo(() => !!sync.project)
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
@@ -805,8 +805,9 @@ export default function Page() {
|
|||||||
|
|
||||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||||
|
|
||||||
createEffect(
|
const [sessionSync] = createResource(
|
||||||
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
|
() => [sdk.directory, params.id] as const,
|
||||||
|
([directory, id]) => {
|
||||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||||
refreshFrame = undefined
|
refreshFrame = undefined
|
||||||
@@ -817,13 +818,10 @@ export default function Page() {
|
|||||||
const stale = !cached
|
const stale = !cached
|
||||||
? false
|
? false
|
||||||
: (() => {
|
: (() => {
|
||||||
const info = getSessionPrefetch(sdk.directory, id)
|
const info = getSessionPrefetch(directory, id)
|
||||||
if (!info) return true
|
if (!info) return true
|
||||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||||
})()
|
})()
|
||||||
untrack(() => {
|
|
||||||
void sync.session.sync(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
refreshFrame = requestAnimationFrame(() => {
|
refreshFrame = requestAnimationFrame(() => {
|
||||||
refreshFrame = undefined
|
refreshFrame = undefined
|
||||||
@@ -835,7 +833,9 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
}),
|
|
||||||
|
return sync.session.sync(id)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -1882,6 +1882,7 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||||
|
{sessionSync() ?? ""}
|
||||||
<SessionHeader />
|
<SessionHeader />
|
||||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||||
<Show when={!isDesktop() && !!params.id}>
|
<Show when={!isDesktop() && !!params.id}>
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function createSdkForServer({
|
|||||||
|
|
||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
...config,
|
...config,
|
||||||
headers: { ...config.headers, ...auth },
|
headers: {
|
||||||
|
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
|
||||||
|
...auth,
|
||||||
|
},
|
||||||
baseUrl: server.url,
|
baseUrl: server.url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIEvent } from "@solidjs/start"
|
import type { APIEvent } from "@solidjs/start"
|
||||||
import type { DownloadPlatform } from "../types"
|
import type { DownloadPlatform } from "../types"
|
||||||
|
|
||||||
const assetNames: Record<string, string> = {
|
const prodAssetNames: Record<string, string> = {
|
||||||
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
|
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
|
||||||
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
|
||||||
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
|
||||||
@@ -10,6 +10,15 @@ const assetNames: Record<string, string> = {
|
|||||||
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
|
||||||
} satisfies Record<DownloadPlatform, string>
|
} satisfies Record<DownloadPlatform, string>
|
||||||
|
|
||||||
|
const betaAssetNames: Record<string, string> = {
|
||||||
|
"darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg",
|
||||||
|
"darwin-x64-dmg": "opencode-electron-mac-x64.dmg",
|
||||||
|
"windows-x64-nsis": "opencode-electron-win-x64.exe",
|
||||||
|
"linux-x64-deb": "opencode-electron-linux-amd64.deb",
|
||||||
|
"linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage",
|
||||||
|
"linux-x64-rpm": "opencode-electron-linux-x86_64.rpm",
|
||||||
|
} satisfies Record<DownloadPlatform, string>
|
||||||
|
|
||||||
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
|
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
|
||||||
const downloadNames: Record<string, string> = {
|
const downloadNames: Record<string, string> = {
|
||||||
"darwin-aarch64-dmg": "OpenCode Desktop.dmg",
|
"darwin-aarch64-dmg": "OpenCode Desktop.dmg",
|
||||||
@@ -18,7 +27,7 @@ const downloadNames: Record<string, string> = {
|
|||||||
} satisfies { [K in DownloadPlatform]?: string }
|
} satisfies { [K in DownloadPlatform]?: string }
|
||||||
|
|
||||||
export async function GET({ params: { platform, channel } }: APIEvent) {
|
export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||||
const assetName = assetNames[platform]
|
const assetName = channel === "stable" ? prodAssetNames[platform] : betaAssetNames[platform]
|
||||||
if (!assetName) return new Response(null, { status: 404 })
|
if (!assetName) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
@@ -37,5 +46,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) {
|
|||||||
const headers = new Headers(resp.headers)
|
const headers = new Headers(resp.headers)
|
||||||
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
|
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
|
||||||
|
|
||||||
return new Response(resp.body, { ...resp, headers })
|
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Header } from "~/component/header"
|
|||||||
import { Footer } from "~/component/footer"
|
import { Footer } from "~/component/footer"
|
||||||
import { Legal } from "~/component/legal"
|
import { Legal } from "~/component/legal"
|
||||||
import { github } from "~/lib/github"
|
import { github } from "~/lib/github"
|
||||||
import { createMemo } from "solid-js"
|
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
@@ -30,7 +29,7 @@ function CopyStatus() {
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const githubData = createAsync(() => github())
|
const _githubData = createAsync(() => github())
|
||||||
const handleCopyClick = (event: Event) => {
|
const handleCopyClick = (event: Event) => {
|
||||||
const button = event.currentTarget as HTMLButtonElement
|
const button = event.currentTarget as HTMLButtonElement
|
||||||
const text = button.textContent
|
const text = button.textContent
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
|||||||
|
|
||||||
const setUseBalance = action(async (form: FormData) => {
|
const setUseBalance = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
await withActor(async () => {
|
await withActor(async () => {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error"
|
|||||||
|
|
||||||
const setMonthlyLimit = action(async (form: FormData) => {
|
const setMonthlyLimit = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const limit = form.get("limit")?.toString()
|
const limit = form.get("limit") as string | null
|
||||||
if (!limit) return { error: formError.limitRequired }
|
if (!limit) return { error: formError.limitRequired }
|
||||||
const numericLimit = parseInt(limit)
|
const numericLimit = parseInt(limit)
|
||||||
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz
|
|||||||
|
|
||||||
const reload = action(async (form: FormData) => {
|
const reload = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||||
revalidate: queryBillingInfo.key,
|
revalidate: queryBillingInfo.key,
|
||||||
@@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => {
|
|||||||
|
|
||||||
const setReload = action(async (form: FormData) => {
|
const setReload = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const reloadValue = form.get("reload")?.toString() === "true"
|
const reloadValue = (form.get("reload") as string | null) === "true"
|
||||||
const amountStr = form.get("reloadAmount")?.toString()
|
const amountStr = form.get("reloadAmount") as string | null
|
||||||
const triggerStr = form.get("reloadTrigger")?.toString()
|
const triggerStr = form.get("reloadTrigger") as string | null
|
||||||
|
|
||||||
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
|
||||||
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
|
||||||
@@ -91,8 +91,8 @@ export function ReloadSection() {
|
|||||||
const info = billingInfo()!
|
const info = billingInfo()!
|
||||||
setStore("show", true)
|
setStore("show", true)
|
||||||
setStore("reload", true)
|
setStore("reload", true)
|
||||||
setStore("reloadAmount", info.reloadAmount.toString())
|
setStore("reloadAmount", String(info.reloadAmount))
|
||||||
setStore("reloadTrigger", info.reloadTrigger.toString())
|
setStore("reloadTrigger", String(info.reloadTrigger))
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
@@ -152,11 +152,11 @@ export function ReloadSection() {
|
|||||||
data-component="input"
|
data-component="input"
|
||||||
name="reloadAmount"
|
name="reloadAmount"
|
||||||
type="number"
|
type="number"
|
||||||
min={billingInfo()?.reloadAmountMin.toString()}
|
min={String(billingInfo()?.reloadAmountMin ?? "")}
|
||||||
step="1"
|
step="1"
|
||||||
value={store.reloadAmount}
|
value={store.reloadAmount}
|
||||||
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
|
||||||
placeholder={billingInfo()?.reloadAmount.toString()}
|
placeholder={String(billingInfo()?.reloadAmount ?? "")}
|
||||||
disabled={!store.reload}
|
disabled={!store.reload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,11 +166,11 @@ export function ReloadSection() {
|
|||||||
data-component="input"
|
data-component="input"
|
||||||
name="reloadTrigger"
|
name="reloadTrigger"
|
||||||
type="number"
|
type="number"
|
||||||
min={billingInfo()?.reloadTriggerMin.toString()}
|
min={String(billingInfo()?.reloadTriggerMin ?? "")}
|
||||||
step="1"
|
step="1"
|
||||||
value={store.reloadTrigger}
|
value={store.reloadTrigger}
|
||||||
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
|
||||||
placeholder={billingInfo()?.reloadTrigger.toString()}
|
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
|
||||||
disabled={!store.reload}
|
disabled={!store.reload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
|||||||
|
|
||||||
const setLiteUseBalance = action(async (form: FormData) => {
|
const setLiteUseBalance = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
const useBalance = (form.get("useBalance") as string | null) === "true"
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
await withActor(async () => {
|
await withActor(async () => {
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error"
|
|||||||
|
|
||||||
const removeKey = action(async (form: FormData) => {
|
const removeKey = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const id = form.get("id")?.toString()
|
const id = form.get("id") as string | null
|
||||||
if (!id) return { error: formError.idRequired }
|
if (!id) return { error: formError.idRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
|
||||||
}, "key.remove")
|
}, "key.remove")
|
||||||
|
|
||||||
const createKey = action(async (form: FormData) => {
|
const createKey = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const name = form.get("name")?.toString().trim()
|
const name = (form.get("name") as string | null)?.trim()
|
||||||
if (!name) return { error: formError.nameRequired }
|
if (!name) return { error: formError.nameRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => {
|
|||||||
|
|
||||||
const inviteMember = action(async (form: FormData) => {
|
const inviteMember = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const email = form.get("email")?.toString().trim()
|
const email = (form.get("email") as string | null)?.trim()
|
||||||
if (!email) return { error: formError.emailRequired }
|
if (!email) return { error: formError.emailRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||||
if (!role) return { error: formError.roleRequired }
|
if (!role) return { error: formError.roleRequired }
|
||||||
const limit = form.get("limit")?.toString()
|
const limit = form.get("limit") as string | null
|
||||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||||
return json(
|
return json(
|
||||||
@@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
|
|||||||
|
|
||||||
const removeMember = action(async (form: FormData) => {
|
const removeMember = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const id = form.get("id")?.toString()
|
const id = form.get("id") as string | null
|
||||||
if (!id) return { error: formError.idRequired }
|
if (!id) return { error: formError.idRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
@@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => {
|
|||||||
const updateMember = action(async (form: FormData) => {
|
const updateMember = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
const id = form.get("id")?.toString()
|
const id = form.get("id") as string | null
|
||||||
if (!id) return { error: formError.idRequired }
|
if (!id) return { error: formError.idRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
const role = form.get("role") as (typeof UserRole)[number] | null
|
||||||
if (!role) return { error: formError.roleRequired }
|
if (!role) return { error: formError.roleRequired }
|
||||||
const limit = form.get("limit")?.toString()
|
const limit = form.get("limit") as string | null
|
||||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ function MemberRow(props: {
|
|||||||
}
|
}
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
setStore("selectedRole", props.member.role)
|
setStore("selectedRole", props.member.role)
|
||||||
setStore("limit", props.member.monthlyLimit?.toString() ?? "")
|
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
|||||||
|
|
||||||
const updateModel = action(async (form: FormData) => {
|
const updateModel = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const model = form.get("model")?.toString()
|
const model = form.get("model") as string | null
|
||||||
if (!model) return { error: formError.modelRequired }
|
if (!model) return { error: formError.modelRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
const enabled = form.get("enabled")?.toString() === "true"
|
const enabled = (form.get("enabled") as string | null) === "true"
|
||||||
return json(
|
return json(
|
||||||
withActor(async () => {
|
withActor(async () => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -163,7 +163,7 @@ export function ModelSection() {
|
|||||||
<form action={updateModel} method="post">
|
<form action={updateModel} method="post">
|
||||||
<input type="hidden" name="model" value={id} />
|
<input type="hidden" name="model" value={id} />
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
<input type="hidden" name="enabled" value={isEnabled().toString()} />
|
<input type="hidden" name="enabled" value={String(isEnabled())} />
|
||||||
<label data-slot="model-toggle-label">
|
<label data-slot="model-toggle-label">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ function maskCredentials(credentials: string) {
|
|||||||
|
|
||||||
const removeProvider = action(async (form: FormData) => {
|
const removeProvider = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const provider = form.get("provider")?.toString()
|
const provider = form.get("provider") as string | null
|
||||||
if (!provider) return { error: formError.providerRequired }
|
if (!provider) return { error: formError.providerRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
||||||
revalidate: listProviders.key,
|
revalidate: listProviders.key,
|
||||||
@@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => {
|
|||||||
|
|
||||||
const saveProvider = action(async (form: FormData) => {
|
const saveProvider = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const provider = form.get("provider")?.toString()
|
const provider = form.get("provider") as string | null
|
||||||
const credentials = form.get("credentials")?.toString()
|
const credentials = form.get("credentials") as string | null
|
||||||
if (!provider) return { error: formError.providerRequired }
|
if (!provider) return { error: formError.providerRequired }
|
||||||
if (!credentials) return { error: formError.apiKeyRequired }
|
if (!credentials) return { error: formError.apiKeyRequired }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
@@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const providers = createAsync(() => listProviders(params.id!))
|
const providers = createAsync(() => listProviders(params.id!))
|
||||||
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
|
const saveSubmission = useSubmission(
|
||||||
|
saveProvider,
|
||||||
|
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||||
|
)
|
||||||
const removeSubmission = useSubmission(
|
const removeSubmission = useSubmission(
|
||||||
removeProvider,
|
removeProvider,
|
||||||
([fd]) => fd.get("provider")?.toString() === props.provider.key,
|
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
|
||||||
)
|
)
|
||||||
const [store, setStore] = createStore({ editing: false })
|
const [store, setStore] = createStore({ editing: false })
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
|
|||||||
|
|
||||||
const updateWorkspace = action(async (form: FormData) => {
|
const updateWorkspace = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const name = form.get("name")?.toString().trim()
|
const name = (form.get("name") as string | null)?.trim()
|
||||||
if (!name) return { error: formError.workspaceNameRequired }
|
if (!name) return { error: formError.workspaceNameRequired }
|
||||||
if (name.length > 255) return { error: formError.nameTooLong }
|
if (name.length > 255) return { error: formError.nameTooLong }
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
return json(
|
return json(
|
||||||
await withActor(
|
await withActor(
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export default defineConfig({
|
|||||||
plugins: [appPlugin],
|
plugins: [appPlugin],
|
||||||
publicDir: "../../../app/public",
|
publicDir: "../../../app/public",
|
||||||
root: "src/renderer",
|
root: "src/renderer",
|
||||||
|
define: {
|
||||||
|
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
research
|
research
|
||||||
dist
|
dist
|
||||||
|
dist-*
|
||||||
gen
|
gen
|
||||||
app.log
|
app.log
|
||||||
src/provider/models-snapshot.js
|
src/provider/models-snapshot.js
|
||||||
src/provider/models-snapshot.d.ts
|
src/provider/models-snapshot.d.ts
|
||||||
|
script/build-*.ts
|
||||||
|
temporary-*.md
|
||||||
|
|||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": ".opencode",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@opencode-ai/plugin": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@opencode-ai/plugin": {
|
|
||||||
"version": "1.2.6",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
|
||||||
"zod": "4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@opencode-ai/sdk": {
|
|
||||||
"version": "1.2.6",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "4.1.8",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,12 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
|||||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||||
|
- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
|
||||||
|
- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
|
||||||
|
|
||||||
|
## Effect v4 beta API
|
||||||
|
|
||||||
|
- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
|
||||||
|
|
||||||
## Preferred Effect services
|
## Preferred Effect services
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||||
|
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
||||||
"db": "bun drizzle-kit"
|
"db": "bun drizzle-kit"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -78,15 +79,15 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
"@ai-sdk/amazon-bedrock": "4.0.94",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.70",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
"@ai-sdk/gateway": "3.0.97",
|
"@ai-sdk/gateway": "3.0.102",
|
||||||
"@ai-sdk/google": "3.0.63",
|
"@ai-sdk/google": "3.0.63",
|
||||||
"@ai-sdk/google-vertex": "4.0.109",
|
"@ai-sdk/google-vertex": "4.0.111",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.53",
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Collapse a single-namespace barrel directory into a dir/index.ts module.
|
||||||
|
*
|
||||||
|
* Given a directory `src/foo/` that contains:
|
||||||
|
*
|
||||||
|
* - `index.ts` (exactly `export * as Foo from "./foo"`)
|
||||||
|
* - `foo.ts` (the real implementation)
|
||||||
|
* - zero or more sibling files
|
||||||
|
*
|
||||||
|
* this script:
|
||||||
|
*
|
||||||
|
* 1. Deletes the old `index.ts` barrel.
|
||||||
|
* 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
|
||||||
|
* 3. Appends `export * as Foo from "."` to the new `index.ts`.
|
||||||
|
* 4. Rewrites any same-directory sibling `*.ts` files that imported
|
||||||
|
* `./foo` (with or without the namespace name) to import `"."` instead.
|
||||||
|
*
|
||||||
|
* Consumer files outside the directory keep importing from the directory
|
||||||
|
* (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
|
||||||
|
* `dir/index.ts` now provides the `Foo` named export directly.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* bun script/collapse-barrel.ts src/bus
|
||||||
|
* bun script/collapse-barrel.ts src/bus --dry-run
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
*
|
||||||
|
* - Only works on directories whose barrel is a single
|
||||||
|
* `export * as Name from "./file"` line. Refuses otherwise.
|
||||||
|
* - Refuses if the implementation file name already conflicts with
|
||||||
|
* `index.ts`.
|
||||||
|
* - Safe to run repeatedly: a second run on an already-collapsed dir
|
||||||
|
* will exit with a clear message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const dryRun = args.includes("--dry-run")
|
||||||
|
const targetArg = args.find((a) => !a.startsWith("--"))
|
||||||
|
|
||||||
|
if (!targetArg) {
|
||||||
|
console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.resolve(targetArg)
|
||||||
|
const indexPath = path.join(dir, "index.ts")
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||||
|
console.error(`Not a directory: ${dir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
console.error(`No index.ts in ${dir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate barrel shape.
|
||||||
|
const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
|
||||||
|
const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
|
||||||
|
if (!match) {
|
||||||
|
console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const namespaceName = match[1]
|
||||||
|
const implRel = match[2].replace(/\.ts$/, "")
|
||||||
|
const implPath = path.join(dir, `${implRel}.ts`)
|
||||||
|
|
||||||
|
if (!fs.existsSync(implPath)) {
|
||||||
|
console.error(`Implementation file not found: ${implPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (implRel === "index") {
|
||||||
|
console.error(`Nothing to do — impl file is already index.ts`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
|
||||||
|
console.log(` namespace: ${namespaceName}`)
|
||||||
|
console.log(` impl file: ${implRel}.ts → index.ts`)
|
||||||
|
|
||||||
|
// Figure out which sibling files need rewriting.
|
||||||
|
const siblings = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
|
||||||
|
.filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
|
||||||
|
.map((f) => path.join(dir, f))
|
||||||
|
|
||||||
|
type SiblingEdit = { file: string; content: string }
|
||||||
|
const siblingEdits: SiblingEdit[] = []
|
||||||
|
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
const content = fs.readFileSync(sibling, "utf-8")
|
||||||
|
// Match any import or re-export referring to "./<implRel>" inside this directory.
|
||||||
|
const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
|
||||||
|
if (!siblingRegex.test(content)) continue
|
||||||
|
const updated = content.replace(siblingRegex, `$1.$2`)
|
||||||
|
siblingEdits.push({ file: sibling, content: updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siblingEdits.length > 0) {
|
||||||
|
console.log(` sibling rewrites: ${siblingEdits.length}`)
|
||||||
|
for (const edit of siblingEdits) {
|
||||||
|
console.log(` ${path.relative(process.cwd(), edit.file)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` sibling rewrites: none`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`\n(dry run) would:`)
|
||||||
|
console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
|
||||||
|
console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
|
||||||
|
console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
|
||||||
|
for (const edit of siblingEdits) {
|
||||||
|
console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
|
||||||
|
// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
|
||||||
|
function runGit(...cmd: string[]) {
|
||||||
|
const res = spawnSync("git", cmd, { stdio: "inherit" })
|
||||||
|
if (res.status !== 0) {
|
||||||
|
console.error(`git ${cmd.join(" ")} failed`)
|
||||||
|
process.exit(res.status ?? 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: remove the barrel
|
||||||
|
runGit("rm", "-f", indexPath)
|
||||||
|
|
||||||
|
// Step 2: rename the impl file into index.ts
|
||||||
|
runGit("mv", implPath, indexPath)
|
||||||
|
|
||||||
|
// Step 3: append the self-reexport to the new index.ts
|
||||||
|
const newContent = fs.readFileSync(indexPath, "utf-8")
|
||||||
|
const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
|
||||||
|
fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
|
||||||
|
console.log(` appended: export * as ${namespaceName} from "."`)
|
||||||
|
|
||||||
|
// Step 4: rewrite siblings
|
||||||
|
for (const edit of siblingEdits) {
|
||||||
|
fs.writeFileSync(edit.file, edit.content)
|
||||||
|
}
|
||||||
|
if (siblingEdits.length > 0) {
|
||||||
|
console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Verify with:`)
|
||||||
|
console.log(` cd packages/opencode`)
|
||||||
|
console.log(` bunx --bun tsgo --noEmit`)
|
||||||
|
console.log(` bun run --conditions=browser ./src/index.ts generate`)
|
||||||
|
console.log(` bun run test`)
|
||||||
@@ -68,23 +68,6 @@ function findBinary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareBinDirectory(binaryName) {
|
|
||||||
const binDir = path.join(__dirname, "bin")
|
|
||||||
const targetPath = path.join(binDir, binaryName)
|
|
||||||
|
|
||||||
// Ensure bin directory exists
|
|
||||||
if (!fs.existsSync(binDir)) {
|
|
||||||
fs.mkdirSync(binDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing binary/symlink if it exists
|
|
||||||
if (fs.existsSync(targetPath)) {
|
|
||||||
fs.unlinkSync(targetPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { binDir, targetPath }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
if (os.platform() === "win32") {
|
if (os.platform() === "win32") {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Config } from "../src/config"
|
import { Config } from "../src/config"
|
||||||
import { TuiConfig } from "../src/config/tui"
|
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
function generate(schema: z.ZodType) {
|
function generate(schema: z.ZodType) {
|
||||||
const result = z.toJSONSchema(schema, {
|
const result = z.toJSONSchema(schema, {
|
||||||
@@ -33,7 +33,7 @@ function generate(schema: z.ZodType) {
|
|||||||
schema.examples = [schema.default]
|
schema.examples = [schema.default]
|
||||||
}
|
}
|
||||||
|
|
||||||
schema.description = [schema.description || "", `default: \`${schema.default}\``]
|
schema.description = [schema.description || "", `default: \`${String(schema.default)}\``]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
|
||||||
|
await import(toDynamicallyImport)
|
||||||
|
console.log(performance.now())
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import * as path from "path"
|
||||||
|
import * as ts from "typescript"
|
||||||
|
|
||||||
|
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
|
||||||
|
|
||||||
|
// Get entry file from command line arg or use default
|
||||||
|
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
|
||||||
|
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
function resolveImport(importPath: string, fromFile: string): string | null {
|
||||||
|
if (importPath.startsWith("@/")) {
|
||||||
|
return path.join(BASE_DIR, "src", importPath.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importPath.startsWith("./") || importPath.startsWith("../")) {
|
||||||
|
const dir = path.dirname(fromFile)
|
||||||
|
return path.resolve(dir, importPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalImport(importPath: string): boolean {
|
||||||
|
return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryExtensions(filePath: string): Promise<string | null> {
|
||||||
|
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = Bun.file(filePath)
|
||||||
|
const stat = await file.stat()
|
||||||
|
|
||||||
|
if (stat?.isDirectory()) {
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const indexPath = path.join(filePath, "index" + ext)
|
||||||
|
const indexFile = Bun.file(indexPath)
|
||||||
|
if (await indexFile.exists()) return indexPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file
|
||||||
|
return filePath
|
||||||
|
} catch {
|
||||||
|
// Path doesn't exist, try adding extensions
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const withExt = filePath + ext
|
||||||
|
const extFile = Bun.file(withExt)
|
||||||
|
if (await extFile.exists()) return withExt
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImports(sourceFile: ts.SourceFile): string[] {
|
||||||
|
const imports: string[] = []
|
||||||
|
|
||||||
|
function visit(node: ts.Node) {
|
||||||
|
// import x from "path" or import { x } from "path"
|
||||||
|
if (ts.isImportDeclaration(node)) {
|
||||||
|
// Skip type-only imports
|
||||||
|
if (node.importClause?.isTypeOnly) return
|
||||||
|
|
||||||
|
const moduleSpec = node.moduleSpecifier
|
||||||
|
if (ts.isStringLiteral(moduleSpec)) {
|
||||||
|
imports.push(moduleSpec.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export { x } from "path"
|
||||||
|
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||||
|
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
||||||
|
imports.push(node.moduleSpecifier.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import: import("path")
|
||||||
|
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||||
|
const arg = node.arguments[0]
|
||||||
|
if (arg && ts.isStringLiteral(arg)) {
|
||||||
|
imports.push(arg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile)
|
||||||
|
return imports
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traceFile(filePath: string, depth = 0): Promise<void> {
|
||||||
|
const normalizedPath = path.relative(BASE_DIR, filePath)
|
||||||
|
|
||||||
|
if (visited.has(filePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trace TypeScript/JavaScript files
|
||||||
|
if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(filePath)
|
||||||
|
console.log("\t".repeat(depth) + normalizedPath)
|
||||||
|
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = await Bun.file(filePath).text()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
|
||||||
|
|
||||||
|
const imports = extractImports(sourceFile)
|
||||||
|
const internalImports = imports.filter(isInternalImport)
|
||||||
|
const externalImports = imports.filter((imp) => !isInternalImport(imp))
|
||||||
|
|
||||||
|
// Print external imports
|
||||||
|
for (const imp of externalImports) {
|
||||||
|
console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const imp of internalImports) {
|
||||||
|
const resolved = resolveImport(imp, filePath)
|
||||||
|
if (!resolved) continue
|
||||||
|
|
||||||
|
const actualPath = await tryExtensions(resolved)
|
||||||
|
if (!actualPath) continue
|
||||||
|
|
||||||
|
await traceFile(actualPath, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const entryPath = path.join(BASE_DIR, ENTRY_FILE)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const file = Bun.file(entryPath)
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
console.error(`File not found: ${ENTRY_FILE}`)
|
||||||
|
console.error(`Resolved to: ${entryPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await traceFile(entryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
@@ -1,444 +1,256 @@
|
|||||||
# Namespace → flat export migration
|
# Namespace → self-reexport migration
|
||||||
|
|
||||||
Migrate `export namespace` to the `export * as` / flat-export pattern used by
|
Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
|
||||||
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
|
single self-reexport line at the bottom of the same file:
|
||||||
conventions, LLM-friendliness for future migrations.
|
|
||||||
|
|
||||||
## What changes and what doesn't
|
|
||||||
|
|
||||||
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
|
|
||||||
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
|
|
||||||
|
|
||||||
What changes is **how** the namespace is constructed — the TypeScript
|
|
||||||
`export namespace` keyword is replaced by `export * as` in a barrel file. This
|
|
||||||
is a mechanical change: unwrap the namespace body into flat exports, add a
|
|
||||||
one-line barrel. Consumers that import `{ Provider }` don't notice.
|
|
||||||
|
|
||||||
Import paths actually get **nicer**. Today most consumers import from the
|
|
||||||
explicit file (`"../provider/provider"`). After the migration, each module has a
|
|
||||||
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// BEFORE — points at the file directly
|
export * as Foo from "./foo"
|
||||||
import { Provider } from "../provider/provider"
|
|
||||||
|
|
||||||
// AFTER — resolves to provider/index.ts, same Provider namespace
|
|
||||||
import { Provider } from "../provider"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why this matters right now
|
No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
|
||||||
|
exact same `import { Foo } from "../foo/foo"` ergonomics.
|
||||||
|
|
||||||
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
|
## Why this pattern
|
||||||
massive dependency graphs that are never actually used at runtime — because
|
|
||||||
bundlers cannot tree-shake TypeScript `export namespace` bodies.
|
|
||||||
|
|
||||||
### The problem in one sentence
|
We tested three options against Bun, esbuild, Rollup (what Vite uses under the
|
||||||
|
hood), Bun's runtime, and Node's native TypeScript runner.
|
||||||
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
|
|
||||||
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
|
|
||||||
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
|
|
||||||
`google-auth-library`, and every other top-level import in that 1709-line file.
|
|
||||||
|
|
||||||
### Why `export namespace` defeats tree-shaking
|
|
||||||
|
|
||||||
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// TypeScript output
|
|
||||||
export var Provider;
|
|
||||||
(function (Provider) {
|
|
||||||
Provider.ModelNotFoundError = NamedError.create(...)
|
|
||||||
// ... 1600 more lines of assignments ...
|
|
||||||
})(Provider || (Provider = {}))
|
|
||||||
```
|
|
||||||
|
|
||||||
This is **opaque to static analysis**. The bundler sees one big function call
|
|
||||||
whose return value populates an object. It cannot determine which properties are
|
|
||||||
used downstream, so it keeps everything. Every `import` statement at the top of
|
|
||||||
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
|
|
||||||
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
|
|
||||||
|
|
||||||
### What `export * as` does differently
|
|
||||||
|
|
||||||
`export * as Provider from "./provider"` compiles to a static re-export. The
|
|
||||||
bundler knows the exact shape of `Provider` at compile time — it's the named
|
|
||||||
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
|
|
||||||
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
|
|
||||||
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
|
|
||||||
object still exists at runtime — same API — but the bundler can see inside it.
|
|
||||||
|
|
||||||
### Concrete impact
|
|
||||||
|
|
||||||
The worst import chain in the codebase:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/index.ts (entry point)
|
heavy.ts loaded?
|
||||||
|
A. namespace B. barrel C. self-reexport
|
||||||
|
Bun bundler YES YES no
|
||||||
|
esbuild YES YES no
|
||||||
|
Rollup (Vite) YES YES no
|
||||||
|
Bun runtime YES YES no
|
||||||
|
Node --experimental-strip-types SYNTAX ERROR YES no
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
|
||||||
|
call and can't analyze what's used. Node's native TS runner rejects the
|
||||||
|
syntax outright: `SyntaxError: TypeScript namespace declaration is not
|
||||||
|
supported in strip-only mode`.
|
||||||
|
- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
|
||||||
|
file) force every re-exported sibling to evaluate when you import one name.
|
||||||
|
Siblings with side effects (top-level imports of SDKs, etc.) always load.
|
||||||
|
- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
|
||||||
|
exports. The module is only pulled in when something actually imports from
|
||||||
|
it. There is no barrel hop, so no sibling contamination and no circular
|
||||||
|
import hazard.
|
||||||
|
|
||||||
|
Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
|
||||||
|
(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
|
||||||
|
negligible for a CLI binary.
|
||||||
|
|
||||||
|
## The pattern
|
||||||
|
|
||||||
|
### Before
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/permission/arity.ts
|
||||||
|
export namespace BashArity {
|
||||||
|
export function prefix(tokens: string[]) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/permission/arity.ts
|
||||||
|
export function prefix(tokens: string[]) { ... }
|
||||||
|
|
||||||
|
export * as BashArity from "./arity"
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers don't change at all:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { BashArity } from "@/permission/arity"
|
||||||
|
BashArity.prefix(...) // still works
|
||||||
|
```
|
||||||
|
|
||||||
|
Editors still auto-import `BashArity` like any named export, because the file
|
||||||
|
does have a named `BashArity` export at the module top level.
|
||||||
|
|
||||||
|
### Odd but harmless
|
||||||
|
|
||||||
|
`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
|
||||||
|
namespace contains a re-export of itself. Nobody would write that. Not a
|
||||||
|
problem.
|
||||||
|
|
||||||
|
## Why this is different from what we tried first
|
||||||
|
|
||||||
|
An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
|
||||||
|
That turned out to be wrong for our constraints:
|
||||||
|
|
||||||
|
1. The barrel file always loads all its sibling modules when you import
|
||||||
|
through it, even if you only need one. For our CLI this is exactly the
|
||||||
|
cost we're trying to avoid.
|
||||||
|
2. Barrel + sibling imports made it very easy to accidentally create circular
|
||||||
|
imports that only surface as `ReferenceError` at runtime, not at
|
||||||
|
typecheck.
|
||||||
|
|
||||||
|
The self-reexport has none of those issues. There is no indirection. The
|
||||||
|
file and the namespace are the same unit.
|
||||||
|
|
||||||
|
## Why this matters for startup
|
||||||
|
|
||||||
|
The worst import chain in the codebase looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/index.ts
|
||||||
└── FormatError from src/cli/error.ts
|
└── FormatError from src/cli/error.ts
|
||||||
├── { Provider } from provider/provider.ts (1709 lines)
|
├── { Provider } from provider/provider.ts (~1700 lines)
|
||||||
│ ├── 20+ @ai-sdk/* packages
|
│ ├── 20+ @ai-sdk/* packages
|
||||||
│ ├── @aws-sdk/credential-providers
|
│ ├── @aws-sdk/credential-providers
|
||||||
│ ├── google-auth-library
|
│ ├── google-auth-library
|
||||||
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
|
│ └── more
|
||||||
│ └── fuzzysort, remeda, etc.
|
├── { Config } from config/config.ts (~1600 lines)
|
||||||
├── { Config } from config/config.ts (1663 lines)
|
└── { MCP } from mcp/mcp.ts (~900 lines)
|
||||||
│ ├── jsonc-parser
|
|
||||||
│ ├── LSPServer (all server definitions)
|
|
||||||
│ └── Plugin, Auth, Env, Account, etc.
|
|
||||||
└── { MCP } from mcp/index.ts (930 lines)
|
|
||||||
├── @modelcontextprotocol/sdk (3 transports)
|
|
||||||
└── open (browser launcher)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
|
All of that currently gets pulled in just to do `.isInstance()` on a handful
|
||||||
that needs maybe 200 bytes total. This inflates the binary, increases startup
|
of error classes. The namespace IIFE shape is the main reason bundlers cannot
|
||||||
memory, and slows down initial module evaluation.
|
strip the unused parts. Self-reexport + flat ESM fixes it.
|
||||||
|
|
||||||
### Why this also hurts memory
|
|
||||||
|
|
||||||
Every module-level import is eagerly evaluated. Even with Bun's fast module
|
|
||||||
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
|
|
||||||
Google's auth library allocates objects, closures, and prototype chains that
|
|
||||||
persist for the lifetime of the process. Most CLI commands never use a provider
|
|
||||||
at all.
|
|
||||||
|
|
||||||
## What effect-smol does
|
|
||||||
|
|
||||||
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
|
|
||||||
|
|
||||||
### 1. Each module is a separate file with flat named exports
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Effect.ts — no namespace wrapper, just flat exports
|
|
||||||
export const gen: { ... } = internal.gen
|
|
||||||
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
|
|
||||||
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
|
|
||||||
// ... 230+ individual named exports
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Barrel file uses `export * as` (not `export namespace`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// index.ts
|
|
||||||
export * as Effect from "./Effect.ts"
|
|
||||||
export * as Schema from "./Schema.ts"
|
|
||||||
export * as Stream from "./Stream.ts"
|
|
||||||
// ~134 modules
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
|
|
||||||
bundler knows the **exact shape** at compile time — it's the static export list
|
|
||||||
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
|
|
||||||
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
|
|
||||||
nothing can be dropped.
|
|
||||||
|
|
||||||
### 3. `sideEffects: []` and deep imports
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// package.json
|
|
||||||
{ "sideEffects": [] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus `"./*": "./src/*.ts"` in the exports map, enabling
|
|
||||||
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
|
|
||||||
|
|
||||||
### 4. Errors as flat exports, not class declarations
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Cause.ts
|
|
||||||
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
|
|
||||||
export interface NoSuchElementError extends YieldableError { ... }
|
|
||||||
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
|
|
||||||
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
|
|
||||||
```
|
|
||||||
|
|
||||||
Each error is 4 independent exports: TypeId, interface, constructor (as const),
|
|
||||||
type guard. All individually shakeable.
|
|
||||||
|
|
||||||
## The plan
|
|
||||||
|
|
||||||
The core migration is **Phase 1** — convert `export namespace` to
|
|
||||||
`export * as`. Once that's done, the bundler can tree-shake individual exports
|
|
||||||
within each module. You do NOT need to break things into subfiles for
|
|
||||||
tree-shaking to work — the bundler traces which exports you actually access on
|
|
||||||
the namespace object and drops the rest, including their transitive imports.
|
|
||||||
|
|
||||||
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
|
|
||||||
lower-risk warmup step that can be done before or after the main conversion, and
|
|
||||||
it provides extra resilience against bundler edge cases. But the big win comes
|
|
||||||
from Phase 1.
|
|
||||||
|
|
||||||
### Phase 0 (optional): Pre-split errors into subfiles
|
|
||||||
|
|
||||||
This is a low-risk warmup that provides immediate benefit even before the full
|
|
||||||
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
|
|
||||||
tree-shaking. But it's a good starting point if you want incremental progress:
|
|
||||||
|
|
||||||
**For each namespace that defines errors** (15 files, ~30 error classes total):
|
|
||||||
|
|
||||||
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
|
|
||||||
definitions as top-level named exports:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// provider/errors.ts
|
|
||||||
import z from "zod"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import { ProviderID, ModelID } from "./schema"
|
|
||||||
|
|
||||||
export const ModelNotFoundError = NamedError.create(
|
|
||||||
"ProviderModelNotFoundError",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
modelID: ModelID.zod,
|
|
||||||
suggestions: z.array(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
|
|
||||||
```
|
|
||||||
|
|
||||||
2. In the namespace file, re-export from the errors file to maintain backward
|
|
||||||
compatibility:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// provider/provider.ts — inside the namespace
|
|
||||||
export { ModelNotFoundError, InitError } from "./errors"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update `cli/error.ts` (and any other light consumers) to import directly:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BEFORE
|
|
||||||
import { Provider } from "../provider/provider"
|
|
||||||
Provider.ModelNotFoundError.isInstance(input)
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
|
|
||||||
ProviderModelNotFoundError.isInstance(input)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files to split (Phase 0):**
|
|
||||||
|
|
||||||
| Current file | New errors file | Errors to extract |
|
|
||||||
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
|
|
||||||
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
|
|
||||||
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
|
|
||||||
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
|
|
||||||
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
|
|
||||||
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
|
|
||||||
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
|
|
||||||
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
|
|
||||||
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
|
|
||||||
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
|
|
||||||
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
|
|
||||||
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
|
|
||||||
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
|
|
||||||
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
|
|
||||||
|
|
||||||
### Phase 1: The real migration — `export namespace` → `export * as`
|
|
||||||
|
|
||||||
This is the phase that actually fixes tree-shaking. For each module:
|
|
||||||
|
|
||||||
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
|
|
||||||
keep all the members as top-level `export const` / `export function` / etc.
|
|
||||||
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
|
|
||||||
`bus/bus.ts`), so the barrel can take `index.ts`.
|
|
||||||
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
|
|
||||||
|
|
||||||
The file structure change for a module that's currently a single file:
|
|
||||||
|
|
||||||
```
|
|
||||||
# BEFORE
|
|
||||||
provider/
|
|
||||||
provider.ts ← 1709-line file with `export namespace Provider { ... }`
|
|
||||||
|
|
||||||
# AFTER
|
|
||||||
provider/
|
|
||||||
index.ts ← NEW: `export * as Provider from "./provider"`
|
|
||||||
provider.ts ← SAME file, same name, just unwrap the namespace
|
|
||||||
```
|
|
||||||
|
|
||||||
And the code change is purely removing the wrapper:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BEFORE: provider/provider.ts
|
|
||||||
export namespace Provider {
|
|
||||||
export class Service extends Context.Service<...>()("@opencode/Provider") {}
|
|
||||||
export const layer = Layer.effect(Service, ...)
|
|
||||||
export const ModelNotFoundError = NamedError.create(...)
|
|
||||||
export function parseModel(model: string) { ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: provider/provider.ts — identical exports, no namespace keyword
|
|
||||||
export class Service extends Context.Service<...>()("@opencode/Provider") {}
|
|
||||||
export const layer = Layer.effect(Service, ...)
|
|
||||||
export const ModelNotFoundError = NamedError.create(...)
|
|
||||||
export function parseModel(model: string) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// NEW: provider/index.ts
|
|
||||||
export * as Provider from "./provider"
|
|
||||||
```
|
|
||||||
|
|
||||||
Consumer code barely changes — import path gets shorter:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// BEFORE
|
|
||||||
import { Provider } from "../provider/provider"
|
|
||||||
|
|
||||||
// AFTER — resolves to provider/index.ts, same Provider object
|
|
||||||
import { Provider } from "../provider"
|
|
||||||
```
|
|
||||||
|
|
||||||
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
|
|
||||||
`Provider.layer` works exactly as before. The difference is invisible to
|
|
||||||
consumers but lets the bundler see inside the namespace.
|
|
||||||
|
|
||||||
**Once this is done, you don't need to break anything into subfiles for
|
|
||||||
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
|
|
||||||
depends on `NamedError` + `zod` + the schema file, and drops
|
|
||||||
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
|
|
||||||
`export * as` gives the bundler a static export list it can do inner-graph
|
|
||||||
analysis on — it knows which exports reference which imports.
|
|
||||||
|
|
||||||
**Order of conversion** (by risk / size, do small modules first):
|
|
||||||
|
|
||||||
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
|
|
||||||
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
|
|
||||||
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
|
|
||||||
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
|
|
||||||
|
|
||||||
### Phase 2: Build configuration
|
|
||||||
|
|
||||||
After the module structure supports tree-shaking:
|
|
||||||
|
|
||||||
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
|
|
||||||
`"sideEffects": false`) — this is safe because our services use explicit
|
|
||||||
layer composition, not import-time side effects.
|
|
||||||
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
|
|
||||||
insufficient, evaluate whether the compiled binary path needs an esbuild
|
|
||||||
pre-pass.
|
|
||||||
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
|
|
||||||
— these are factory functions that return classes, and bundlers may not know
|
|
||||||
they're side-effect-free without the annotation.
|
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
|
|
||||||
The transformation is scripted. From `packages/opencode`:
|
From `packages/opencode`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun script/unwrap-namespace.ts <file> [--dry-run]
|
bun script/unwrap-namespace.ts <file> [--dry-run]
|
||||||
```
|
```
|
||||||
|
|
||||||
The script uses ast-grep for accurate AST-based namespace boundary detection
|
The script:
|
||||||
(no false matches from braces in strings/templates/comments), then:
|
|
||||||
|
|
||||||
1. Removes the `export namespace Foo {` line and its closing `}`
|
1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
|
||||||
2. Dedents the body by one indent level (2 spaces)
|
2. Removes the `export namespace Foo {` line and the matching closing `}`.
|
||||||
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
|
3. Dedents the body by one indent level (2 spaces).
|
||||||
`index.ts` barrel
|
4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
|
||||||
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
|
5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
|
||||||
5. Prints the exact commands to find and rewrite import paths
|
6. Never creates a barrel `index.ts`.
|
||||||
|
|
||||||
### Walkthrough: converting a module
|
### Typical flow for one file
|
||||||
|
|
||||||
Using `Provider` as an example:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Preview what will change
|
# 1. Preview
|
||||||
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
|
bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
|
||||||
|
|
||||||
# 2. Apply the transformation
|
# 2. Apply
|
||||||
bun script/unwrap-namespace.ts src/provider/provider.ts
|
bun script/unwrap-namespace.ts src/permission/arity.ts
|
||||||
|
|
||||||
# 3. Rewrite import paths (script prints the exact command)
|
# 3. Verify
|
||||||
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
|
cd packages/opencode
|
||||||
|
bunx --bun tsgo --noEmit
|
||||||
# 4. Verify
|
bun run --conditions=browser ./src/index.ts generate
|
||||||
bun typecheck
|
bun run test <affected test files>
|
||||||
bun run test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**What changes on disk:**
|
### Consumer imports usually don't need to change
|
||||||
|
|
||||||
```
|
Most consumers already import straight from the file, e.g.:
|
||||||
# BEFORE
|
|
||||||
provider/
|
|
||||||
provider.ts ← 1709 lines, `export namespace Provider { ... }`
|
|
||||||
|
|
||||||
# AFTER
|
|
||||||
provider/
|
|
||||||
index.ts ← NEW: `export * as Provider from "./provider"`
|
|
||||||
provider.ts ← same file, namespace unwrapped to flat exports
|
|
||||||
```
|
|
||||||
|
|
||||||
**What changes in consumer code:**
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// BEFORE
|
import { BashArity } from "@/permission/arity"
|
||||||
import { Provider } from "../provider/provider"
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
// AFTER — shorter path, same Provider object
|
|
||||||
import { Provider } from "../provider"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
|
Because the file itself now does `export * as Foo from "./foo"`, those imports
|
||||||
stays identical.
|
keep working with zero edits.
|
||||||
|
|
||||||
### Two cases the script handles
|
The only edits needed are when a consumer was importing through a previous
|
||||||
|
barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
|
||||||
|
that case, repoint it at the file:
|
||||||
|
|
||||||
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
|
```ts
|
||||||
|
// before
|
||||||
|
import { Config } from "@/config"
|
||||||
|
|
||||||
- Rewrites the file in place (unwrap + dedent)
|
// after
|
||||||
- Creates `provider/index.ts` as the barrel
|
import { Config } from "@/config/config"
|
||||||
- Import paths change: `"../provider/provider"` → `"../provider"`
|
```
|
||||||
|
|
||||||
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
|
### Dynamic imports in tests
|
||||||
|
|
||||||
- Renames `index.ts` → `bus.ts` (kebab-case of namespace name)
|
If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
|
||||||
- Creates new `index.ts` as the barrel
|
still works because of the self-reexport. No change required.
|
||||||
- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
|
|
||||||
|
|
||||||
## Do I need to split errors/schemas into subfiles?
|
## Verification checklist (per PR)
|
||||||
|
|
||||||
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
|
Run all of these locally before pushing:
|
||||||
individual exports within the file. If `cli/error.ts` only accesses
|
|
||||||
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
|
|
||||||
doesn't reference `createAnthropic` and drops the AI SDK imports.
|
|
||||||
|
|
||||||
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
|
```bash
|
||||||
organization** — smaller files are easier to read and review. But it's not
|
cd packages/opencode
|
||||||
required for tree-shaking. The `export * as` conversion alone is sufficient.
|
bunx --bun tsgo --noEmit
|
||||||
|
bun run --conditions=browser ./src/index.ts generate
|
||||||
|
bun run test <affected test files>
|
||||||
|
```
|
||||||
|
|
||||||
The one case where subfile splitting provides extra tree-shake value is if an
|
Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
|
||||||
imported package has module-level side effects that the bundler can't prove are
|
consumer is still importing the namespace from an old barrel path that no
|
||||||
unused. In practice this is rare — most npm packages are side-effect-free — and
|
longer exports it.
|
||||||
adding `"sideEffects": []` to package.json handles the common cases.
|
|
||||||
|
|
||||||
## Scope
|
The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
|
||||||
|
evaluates every module eagerly and is the most reliable way to catch circular
|
||||||
| Metric | Count |
|
import regressions at runtime — the typechecker does not catch these.
|
||||||
| ----------------------------------------------- | --------------- |
|
|
||||||
| Files with `export namespace` | 106 |
|
|
||||||
| Total namespace declarations | 118 (12 nested) |
|
|
||||||
| Files with `NamedError.create` inside namespace | 15 |
|
|
||||||
| Total error classes to extract | ~30 |
|
|
||||||
| Files using `export * as` today | 0 |
|
|
||||||
|
|
||||||
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
|
|
||||||
LLM-friendly but touches every import site, so it should be done module by
|
|
||||||
module with type-checking between each step. Each module is an independent PR.
|
|
||||||
|
|
||||||
## Rules for new code
|
## Rules for new code
|
||||||
|
|
||||||
Going forward:
|
- No new `export namespace`.
|
||||||
|
- Every module directory has a single canonical file — typically
|
||||||
|
`dir/index.ts` — with flat top-level exports and a self-reexport at the
|
||||||
|
bottom:
|
||||||
|
`export * as Foo from "."`
|
||||||
|
- Consumers import from the directory:
|
||||||
|
`import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
|
||||||
|
- No sibling barrel files. If a directory has multiple independent
|
||||||
|
namespaces, they each get their own file (e.g. `config/config.ts`,
|
||||||
|
`config/plugin.ts`) and their own self-reexport; the `index.ts` in that
|
||||||
|
directory stays minimal or does not exist.
|
||||||
|
- If a file needs a sibling, import the sibling file directly:
|
||||||
|
`import * as Sibling from "./sibling"`, not `from "."`.
|
||||||
|
|
||||||
- **No new `export namespace`**. Use a file with flat named exports and
|
### Why `dir/index.ts` + `"."` is fine for us
|
||||||
`export * as` in the barrel.
|
|
||||||
- Keep the service, layer, errors, schemas, and runtime wiring together in one
|
A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
|
||||||
file if you want — that's fine now. The `export * as` barrel makes everything
|
with `export * as Foo from "."` at the bottom. Consumers write the
|
||||||
individually shakeable regardless of file structure.
|
short form:
|
||||||
- If a file grows large enough that it's hard to navigate, split by concern
|
|
||||||
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
|
```ts
|
||||||
bundler handles that.
|
import { Pty } from "@/pty"
|
||||||
|
```
|
||||||
|
|
||||||
|
This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
|
||||||
|
work under Node's `--experimental-strip-types` runner:
|
||||||
|
|
||||||
|
```
|
||||||
|
node --experimental-strip-types entry.ts
|
||||||
|
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
|
||||||
|
```
|
||||||
|
|
||||||
|
Node requires an explicit file or a `package.json#exports` map for ESM.
|
||||||
|
We don't care about that target right now because the opencode CLI is
|
||||||
|
built with Bun and the web apps are built with Vite/Rollup. If we ever
|
||||||
|
want to run raw `.ts` through Node, we'll need to either use explicit
|
||||||
|
`.ts` extensions everywhere or add per-directory `package.json` exports
|
||||||
|
maps.
|
||||||
|
|
||||||
|
### When NOT to collapse to `index.ts`
|
||||||
|
|
||||||
|
Some directories contain multiple independent namespaces where
|
||||||
|
`dir/index.ts` would be misleading. Examples:
|
||||||
|
|
||||||
|
- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
|
||||||
|
`ConfigKeybinds`. Each lives in its own file with its own self-reexport
|
||||||
|
(`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
|
||||||
|
specific one: `import { ConfigPlugin } from "@/config/plugin"`.
|
||||||
|
- Same shape for `session/`, `server/`, etc.
|
||||||
|
|
||||||
|
Collapsing one of those into `index.ts` would mean picking a single
|
||||||
|
"canonical" namespace for the directory, which breaks the symmetry and
|
||||||
|
hides the other files.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
There are still dozens of `export namespace` files left across the codebase.
|
||||||
|
Each one is its own small PR. Do them one at a time, verified locally, rather
|
||||||
|
than batching by directory.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage"
|
||||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||||
import { normalizeServerUrl } from "./url"
|
import { normalizeServerUrl } from "./url"
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ import { AppRuntime } from "@/effect/app-runtime"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
import { Config } from "@/config"
|
import { Config } from "@/config"
|
||||||
|
import { ConfigMCP } from "@/config/mcp"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { LoadAPIKeyError } from "ai"
|
import { LoadAPIKeyError } from "ai"
|
||||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { applyPatch } from "diff"
|
import { applyPatch } from "diff"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
type ModeOption = { id: string; name: string; description?: string }
|
type ModeOption = { id: string; name: string; description?: string }
|
||||||
type ModelOption = { modelId: string; name: string }
|
type ModelOption = { modelId: string; name: string }
|
||||||
@@ -176,7 +178,7 @@ export namespace ACP {
|
|||||||
})
|
})
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
if (this.eventAbort.signal.aborted) return
|
if (this.eventAbort.signal.aborted) return
|
||||||
const payload = (event as any)?.payload
|
const payload = event?.payload
|
||||||
if (!payload) continue
|
if (!payload) continue
|
||||||
await this.handleEvent(payload as Event).catch((error) => {
|
await this.handleEvent(payload as Event).catch((error) => {
|
||||||
log.error("failed to handle event", { error, type: payload.type })
|
log.error("failed to handle event", { error, type: payload.type })
|
||||||
@@ -570,7 +572,7 @@ export namespace ACP {
|
|||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: {
|
agentInfo: {
|
||||||
name: "OpenCode",
|
name: "OpenCode",
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1212,7 +1214,7 @@ export namespace ACP {
|
|||||||
description: "compact the session",
|
description: "compact the session",
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpServers: Record<string, Config.Mcp> = {}
|
const mcpServers: Record<string, ConfigMCP.Info> = {}
|
||||||
for (const server of params.mcpServers) {
|
for (const server of params.mcpServers) {
|
||||||
if ("type" in server) {
|
if ("type" in server) {
|
||||||
mcpServers[server.name] = {
|
mcpServers[server.name] = {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { Provider } from "../provider"
|
|||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Truncate } from "../tool/truncate"
|
import { Truncate } from "../tool"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { ProviderTransform } from "../provider/transform"
|
import { ProviderTransform } from "../provider"
|
||||||
|
|
||||||
import PROMPT_GENERATE from "./generate.txt"
|
import PROMPT_GENERATE from "./generate.txt"
|
||||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { Database } from "../../storage/db"
|
import { Database } from "../../storage"
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||||
import { Database as BunDatabase } from "bun:sqlite"
|
import { Database as BunDatabase } from "bun:sqlite"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { JsonMigration } from "../../storage/json-migration"
|
import { JsonMigration } from "../../storage"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Provider } from "../../../provider"
|
|||||||
import { Session } from "../../../session"
|
import { Session } from "../../../session"
|
||||||
import type { MessageV2 } from "../../../session/message-v2"
|
import type { MessageV2 } from "../../../session/message-v2"
|
||||||
import { MessageID, PartID } from "../../../session/schema"
|
import { MessageID, PartID } from "../../../session/schema"
|
||||||
import { ToolRegistry } from "../../../tool/registry"
|
import { ToolRegistry } from "../../../tool"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { Permission } from "../../../permission"
|
import { Permission } from "../../../permission"
|
||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Project } from "../../../project/project"
|
import { Project } from "../../../project"
|
||||||
import { Log } from "../../../util"
|
import { Log } from "../../../util"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import type {
|
|||||||
} from "@octokit/webhooks-types"
|
} from "@octokit/webhooks-types"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { ModelsDev } from "../../provider/models"
|
import { ModelsDev } from "../../provider"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share"
|
||||||
import { Session } from "../../session"
|
import { Session } from "../../session"
|
||||||
import type { SessionID } from "../../session/schema"
|
import type { SessionID } from "../../session/schema"
|
||||||
import { MessageID, PartID } from "../../session/schema"
|
import { MessageID, PartID } from "../../session/schema"
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { Session } from "../../session"
|
|||||||
import { MessageV2 } from "../../session/message-v2"
|
import { MessageV2 } from "../../session/message-v2"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { Database } from "../../storage/db"
|
import { Database } from "../../storage"
|
||||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { ShareNext } from "../../share/share-next"
|
import { ShareNext } from "../../share"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Filesystem } from "../../util"
|
import { Filesystem } from "../../util"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { MCP } from "../../mcp"
|
|||||||
import { McpAuth } from "../../mcp/auth"
|
import { McpAuth } from "../../mcp/auth"
|
||||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
import { ConfigMCP } from "../../config/mcp"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { modify, applyEdits } from "jsonc-parser"
|
import { modify, applyEdits } from "jsonc-parser"
|
||||||
@@ -42,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
|
|||||||
|
|
||||||
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
||||||
|
|
||||||
type McpConfigured = Config.Mcp
|
type McpConfigured = ConfigMCP.Info
|
||||||
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
||||||
return typeof config === "object" && config !== null && "type" in config
|
return typeof config === "object" && config !== null && "type" in config
|
||||||
}
|
}
|
||||||
@@ -425,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
|
|||||||
return candidates[0]
|
return candidates[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
|
async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
|
||||||
let text = "{}"
|
let text = "{}"
|
||||||
if (await Filesystem.exists(configPath)) {
|
if (await Filesystem.exists(configPath)) {
|
||||||
text = await Filesystem.readText(configPath)
|
text = await Filesystem.readText(configPath)
|
||||||
@@ -513,7 +515,7 @@ export const McpAddCommand = cmd({
|
|||||||
})
|
})
|
||||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||||
|
|
||||||
const mcpConfig: Config.Mcp = {
|
const mcpConfig: ConfigMCP.Info = {
|
||||||
type: "local",
|
type: "local",
|
||||||
command: command.split(" "),
|
command: command.split(" "),
|
||||||
}
|
}
|
||||||
@@ -543,7 +545,7 @@ export const McpAddCommand = cmd({
|
|||||||
})
|
})
|
||||||
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||||
|
|
||||||
let mcpConfig: Config.Mcp
|
let mcpConfig: ConfigMCP.Info
|
||||||
|
|
||||||
if (useOAuth) {
|
if (useOAuth) {
|
||||||
const hasClientId = await prompts.confirm({
|
const hasClientId = await prompts.confirm({
|
||||||
@@ -697,7 +699,7 @@ export const McpDebugCommand = cmd({
|
|||||||
params: {
|
params: {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: "2024-11-05",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientInfo: { name: "opencode-debug", version: Installation.VERSION },
|
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||||
},
|
},
|
||||||
id: 1,
|
id: 1,
|
||||||
}),
|
}),
|
||||||
@@ -746,7 +748,7 @@ export const McpDebugCommand = cmd({
|
|||||||
try {
|
try {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
name: "opencode-debug",
|
name: "opencode-debug",
|
||||||
version: Installation.VERSION,
|
version: InstallationVersion,
|
||||||
})
|
})
|
||||||
await client.connect(transport)
|
await client.connect(transport)
|
||||||
prompts.log.success("Connection successful (already authenticated)")
|
prompts.log.success("Connection successful (already authenticated)")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Argv } from "yargs"
|
|||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import { ProviderID } from "../../provider/schema"
|
import { ProviderID } from "../../provider/schema"
|
||||||
import { ModelsDev } from "../../provider/models"
|
import { ModelsDev } from "../../provider"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { intro, log, outro, spinner } from "@clack/prompts"
|
import { intro, log, outro, spinner } from "@clack/prompts"
|
||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
|
|
||||||
import { ConfigPaths } from "../../config/paths"
|
import { ConfigPaths } from "../../config"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
|
||||||
import { resolvePluginTarget } from "../../plugin/shared"
|
import { resolvePluginTarget } from "../../plugin/shared"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AppRuntime } from "../../effect/app-runtime"
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { ModelsDev } from "../../provider/models"
|
import { ModelsDev } from "../../provider"
|
||||||
import { map, pipe, sortBy, values } from "remeda"
|
import { map, pipe, sortBy, values } from "remeda"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
@@ -297,7 +297,9 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
prompts.intro("Add credential")
|
prompts.intro("Add credential")
|
||||||
if (args.url) {
|
if (args.url) {
|
||||||
const url = args.url.replace(/\/+$/, "")
|
const url = args.url.replace(/\/+$/, "")
|
||||||
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
|
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
|
||||||
|
auth: { command: string[]; env: string }
|
||||||
|
}
|
||||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
const proc = Process.spawn(wellknown.auth.command, {
|
const proc = Process.spawn(wellknown.auth.command, {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Server } from "../../server/server"
|
|||||||
import { Provider } from "../../provider"
|
import { Provider } from "../../provider"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { Tool } from "../../tool/tool"
|
import { Tool } from "../../tool"
|
||||||
import { GlobTool } from "../../tool/glob"
|
import { GlobTool } from "../../tool/glob"
|
||||||
import { GrepTool } from "../../tool/grep"
|
import { GrepTool } from "../../tool/grep"
|
||||||
import { ReadTool } from "../../tool/read"
|
import { ReadTool } from "../../tool/read"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { Argv } from "yargs"
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Session } from "../../session"
|
import { Session } from "../../session"
|
||||||
import { bootstrap } from "../bootstrap"
|
import { bootstrap } from "../bootstrap"
|
||||||
import { Database } from "../../storage/db"
|
import { Database } from "../../storage"
|
||||||
import { SessionTable } from "../../session/session.sql"
|
import { SessionTable } from "../../session/session.sql"
|
||||||
import { Project } from "../../project/project"
|
import { Project } from "../../project"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { Selection } from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
import { Terminal } from "@tui/util/terminal"
|
import * as Terminal from "@tui/util/terminal"
|
||||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import {
|
import {
|
||||||
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
|||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
|
||||||
@@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
renderer,
|
renderer,
|
||||||
})
|
})
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
TuiPluginRuntime.init(api)
|
TuiPluginRuntime.init({
|
||||||
|
api,
|
||||||
|
config: tuiConfig,
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to load TUI plugins", error)
|
console.error("Failed to load TUI plugins", error)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { cmd } from "../cmd"
|
|||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
import { tui } from "./app"
|
import { tui } from "./app"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { existsSync } from "fs"
|
|
||||||
|
|
||||||
export const AttachCommand = cmd({
|
export const AttachCommand = cmd({
|
||||||
command: "attach <url>",
|
command: "attach <url>",
|
||||||
@@ -66,10 +64,7 @@ export const AttachCommand = cmd({
|
|||||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||||
return { Authorization: auth }
|
return { Authorization: auth }
|
||||||
})()
|
})()
|
||||||
const config = await Instance.provide({
|
const config = await TuiConfig.get()
|
||||||
directory: directory && existsSync(directory) ? directory : process.cwd(),
|
|
||||||
fn: () => TuiConfig.get(),
|
|
||||||
})
|
|
||||||
await tui({
|
await tui({
|
||||||
url: args.url,
|
url: args.url,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DialogAgent() {
|
|||||||
return (
|
return (
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Select agent"
|
title="Select agent"
|
||||||
current={local.agent.current().name}
|
current={local.agent.current()?.name}
|
||||||
options={options()}
|
options={options()}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
local.agent.set(option.value)
|
local.agent.set(option.value)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { TextAttributes } from "@opentui/core"
|
|||||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||||
import { DialogModel } from "./dialog-model"
|
import { DialogModel } from "./dialog-model"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useDialog } from "../ui/dialog"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
|
||||||
|
export function DialogSessionDeleteFailed(props: {
|
||||||
|
session: string
|
||||||
|
workspace: string
|
||||||
|
onDelete?: () => boolean | void | Promise<boolean | void>
|
||||||
|
onRestore?: () => boolean | void | Promise<boolean | void>
|
||||||
|
onDone?: () => void
|
||||||
|
}) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
active: "delete" as "delete" | "restore",
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
id: "delete" as const,
|
||||||
|
title: "Delete workspace",
|
||||||
|
description: "Delete the workspace and all sessions attached to it.",
|
||||||
|
run: props.onDelete,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "restore" as const,
|
||||||
|
title: "Restore to new workspace",
|
||||||
|
description: "Try to restore this session into a new workspace.",
|
||||||
|
run: props.onRestore,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
const result = await options.find((item) => item.id === store.active)?.run?.()
|
||||||
|
if (result === false) return
|
||||||
|
props.onDone?.()
|
||||||
|
if (!props.onDone) dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.name === "return") {
|
||||||
|
void confirm()
|
||||||
|
}
|
||||||
|
if (evt.name === "left" || evt.name === "up") {
|
||||||
|
setStore("active", "delete")
|
||||||
|
}
|
||||||
|
if (evt.name === "right" || evt.name === "down") {
|
||||||
|
setStore("active", "restore")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
|
Failed to Delete Session
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
Choose how you want to recover this broken workspace session.
|
||||||
|
</text>
|
||||||
|
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||||
|
<For each={options}>
|
||||||
|
{(item) => (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
backgroundColor={item.id === store.active ? theme.primary : undefined}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setStore("active", item.id)
|
||||||
|
void confirm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
attributes={TextAttributes.BOLD}
|
||||||
|
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</text>
|
||||||
|
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
|
||||||
|
{item.description}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
|
|||||||
import { Keybind } from "@/util"
|
import { Keybind } from "@/util"
|
||||||
import { createDebouncedSignal } from "../util/signal"
|
import { createDebouncedSignal } from "../util/signal"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
|
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||||
|
|
||||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ export function DialogSessionList() {
|
|||||||
const [toDelete, setToDelete] = createSignal<string>()
|
const [toDelete, setToDelete] = createSignal<string>()
|
||||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||||
|
|
||||||
const [searchResults] = createResource(search, async (query) => {
|
const [searchResults, { refetch }] = createResource(search, async (query) => {
|
||||||
if (!query) return undefined
|
if (!query) return undefined
|
||||||
const result = await sdk.client.session.list({ search: query, limit: 30 })
|
const result = await sdk.client.session.list({ search: query, limit: 30 })
|
||||||
return result.data ?? []
|
return result.data ?? []
|
||||||
@@ -56,6 +58,57 @@ export function DialogSessionList() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||||
|
const workspace = project.workspace.get(session.workspaceID!)
|
||||||
|
const list = () => dialog.replace(() => <DialogSessionList />)
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSessionDeleteFailed
|
||||||
|
session={session.title}
|
||||||
|
workspace={workspace?.name ?? session.workspaceID!}
|
||||||
|
onDone={list}
|
||||||
|
onDelete={async () => {
|
||||||
|
const current = currentSessionID()
|
||||||
|
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
||||||
|
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
||||||
|
if (result.error) {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete workspace",
|
||||||
|
message: errorMessage(result.error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await project.workspace.sync()
|
||||||
|
await sync.session.refresh()
|
||||||
|
if (search()) await refetch()
|
||||||
|
if (info?.workspaceID === session.workspaceID) {
|
||||||
|
route.navigate({ type: "home" })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
onRestore={() => {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogWorkspaceCreate
|
||||||
|
onSelect={(workspaceID) =>
|
||||||
|
restoreWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID,
|
||||||
|
sessionID: session.id,
|
||||||
|
done: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
return sessions()
|
return sessions()
|
||||||
@@ -145,9 +198,43 @@ export function DialogSessionList() {
|
|||||||
title: "delete",
|
title: "delete",
|
||||||
onTrigger: async (option) => {
|
onTrigger: async (option) => {
|
||||||
if (toDelete() === option.value) {
|
if (toDelete() === option.value) {
|
||||||
void sdk.client.session.delete({
|
const session = sessions().find((item) => item.id === option.value)
|
||||||
sessionID: option.value,
|
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
||||||
})
|
|
||||||
|
try {
|
||||||
|
const result = await sdk.client.session.delete({
|
||||||
|
sessionID: option.value,
|
||||||
|
})
|
||||||
|
if (result.error) {
|
||||||
|
if (session?.workspaceID) {
|
||||||
|
recover(session)
|
||||||
|
} else {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete session",
|
||||||
|
message: errorMessage(result.error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setToDelete(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (session?.workspaceID) {
|
||||||
|
recover(session)
|
||||||
|
} else {
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
title: "Failed to delete session",
|
||||||
|
message: errorMessage(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setToDelete(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status && status !== "connected") {
|
||||||
|
await sync.session.refresh()
|
||||||
|
}
|
||||||
|
if (search()) await refetch()
|
||||||
setToDelete(undefined)
|
setToDelete(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
|
|||||||
import { useProject } from "@tui/context/project"
|
import { useProject } from "@tui/context/project"
|
||||||
import { createMemo, createSignal, onMount } from "solid-js"
|
import { createMemo, createSignal, onMount } from "solid-js"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
|
import * as Log from "@/util/log"
|
||||||
import { useSDK } from "../context/sdk"
|
import { useSDK } from "../context/sdk"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ type Adaptor = {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const log = Log.Default.clone().tag("service", "tui-workspace")
|
||||||
|
|
||||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||||
return createOpencodeClient({
|
return createOpencodeClient({
|
||||||
baseUrl: sdk.url,
|
baseUrl: sdk.url,
|
||||||
@@ -33,8 +37,20 @@ export async function openWorkspaceSession(input: {
|
|||||||
workspaceID: string
|
workspaceID: string
|
||||||
}) {
|
}) {
|
||||||
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
const client = scoped(input.sdk, input.sync, input.workspaceID)
|
||||||
|
log.info("workspace session create requested", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("opening!")
|
||||||
while (true) {
|
while (true) {
|
||||||
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
|
console.log("creating")
|
||||||
|
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
||||||
|
log.error("workspace session create request failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
error: errorData(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
if (!result) {
|
if (!result) {
|
||||||
input.toast.show({
|
input.toast.show({
|
||||||
message: "Failed to create workspace session",
|
message: "Failed to create workspace session",
|
||||||
@@ -42,26 +58,113 @@ export async function openWorkspaceSession(input: {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.response.status >= 500 && result.response.status < 600) {
|
log.info("workspace session create response", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
status: result.response?.status,
|
||||||
|
sessionID: result.data?.id,
|
||||||
|
})
|
||||||
|
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
|
||||||
|
log.warn("workspace session create retrying after server error", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
status: result.response.status,
|
||||||
|
})
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
|
log.error("workspace session create returned no data", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
status: result.response?.status,
|
||||||
|
})
|
||||||
input.toast.show({
|
input.toast.show({
|
||||||
message: "Failed to create workspace session",
|
message: "Failed to create workspace session",
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.route.navigate({
|
input.route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: result.data.id,
|
sessionID: result.data.id,
|
||||||
})
|
})
|
||||||
|
log.info("workspace session create complete", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: result.data.id,
|
||||||
|
})
|
||||||
input.dialog.clear()
|
input.dialog.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreWorkspaceSession(input: {
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
project: ReturnType<typeof useProject>
|
||||||
|
toast: ReturnType<typeof useToast>
|
||||||
|
workspaceID: string
|
||||||
|
sessionID: string
|
||||||
|
done?: () => void
|
||||||
|
}) {
|
||||||
|
log.info("session restore requested", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
const result = await input.sdk.client.experimental.workspace
|
||||||
|
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
|
||||||
|
.catch((err) => {
|
||||||
|
log.error("session restore request failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
error: errorData(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!result?.data) {
|
||||||
|
log.error("session restore failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
status: result?.response?.status,
|
||||||
|
error: result?.error ? errorData(result.error) : undefined,
|
||||||
|
})
|
||||||
|
input.toast.show({
|
||||||
|
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("session restore response", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
status: result.response?.status,
|
||||||
|
total: result.data.total,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
|
||||||
|
log.error("session restore refresh failed", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
error: errorData(err),
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("session restore complete", {
|
||||||
|
workspaceID: input.workspaceID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
total: result.data.total,
|
||||||
|
})
|
||||||
|
|
||||||
|
input.toast.show({
|
||||||
|
message: "Session restored into the new workspace",
|
||||||
|
variant: "success",
|
||||||
|
})
|
||||||
|
input.done?.()
|
||||||
|
if (input.done) return
|
||||||
|
input.dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -123,18 +226,43 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
|||||||
const create = async (type: string) => {
|
const create = async (type: string) => {
|
||||||
if (creating()) return
|
if (creating()) return
|
||||||
setCreating(type)
|
setCreating(type)
|
||||||
|
log.info("workspace create requested", {
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||||
|
log.error("workspace create request failed", {
|
||||||
|
type,
|
||||||
|
error: errorData(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
|
|
||||||
const workspace = result?.data
|
const workspace = result?.data
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
setCreating(undefined)
|
setCreating(undefined)
|
||||||
|
log.error("workspace create failed", {
|
||||||
|
type,
|
||||||
|
status: result?.response.status,
|
||||||
|
error: result?.error ? errorData(result.error) : undefined,
|
||||||
|
})
|
||||||
toast.show({
|
toast.show({
|
||||||
message: "Failed to create workspace",
|
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.info("workspace create response", {
|
||||||
|
type,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
status: result.response?.status,
|
||||||
|
})
|
||||||
|
|
||||||
await project.workspace.sync()
|
await project.workspace.sync()
|
||||||
|
log.info("workspace create synced", {
|
||||||
|
type,
|
||||||
|
workspaceID: workspace.id,
|
||||||
|
})
|
||||||
await props.onSelect(workspace.id)
|
await props.onSelect(workspace.id)
|
||||||
setCreating(undefined)
|
setCreating(undefined)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export function ErrorComponent(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
issueURL.searchParams.set("opencode-version", Installation.VERSION)
|
issueURL.searchParams.set("opencode-version", InstallationVersion)
|
||||||
|
|
||||||
const copyIssueURL = () => {
|
const copyIssueURL = () => {
|
||||||
void Clipboard.copy(issueURL.toString()).then(() => {
|
void Clipboard.copy(issueURL.toString()).then(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
import { useTheme, tint } from "@tui/context/theme"
|
||||||
import { Sound } from "@tui/util/sound"
|
import * as Sound from "@tui/util/sound"
|
||||||
import { logo } from "@/cli/logo"
|
import { logo } from "@/cli/logo"
|
||||||
|
|
||||||
// Shadow markers (rendered chars in parens):
|
// Shadow markers (rendered chars in parens):
|
||||||
@@ -520,7 +520,7 @@ export function Logo() {
|
|||||||
const shadow = tint(theme.background, ink, 0.25)
|
const shadow = tint(theme.background, ink, 0.25)
|
||||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||||
|
|
||||||
return [...line].map((char, i) => {
|
return Array.from(line).map((char, i) => {
|
||||||
const h = field(off + i, y, frame)
|
const h = field(off + i, y, frame)
|
||||||
const n = wave(off + i, y, frame, lit(char)) + h
|
const n = wave(off + i, y, frame, lit(char)) + h
|
||||||
const s = wave(off + i, y, dusk, false) + h
|
const s = wave(off + i, y, dusk, false) + h
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import { DialogStash } from "../dialog-stash"
|
|||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
import { useCommandDialog } from "../dialog-command"
|
import { useCommandDialog } from "../dialog-command"
|
||||||
import { useRenderer, type JSX } from "@opentui/solid"
|
import { useRenderer, type JSX } from "@opentui/solid"
|
||||||
import { Editor } from "@tui/util/editor"
|
import * as Editor from "@tui/util/editor"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Clipboard } from "../../util/clipboard"
|
import * as Clipboard from "../../util/clipboard"
|
||||||
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { TuiEvent } from "../../event"
|
import { TuiEvent } from "../../event"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
@@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return
|
||||||
|
const agent = local.agent.current()
|
||||||
|
if (!agent) return
|
||||||
const trimmed = store.prompt.input.trim()
|
const trimmed = store.prompt.input.trim()
|
||||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||||
void exit()
|
void exit()
|
||||||
@@ -615,9 +617,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
if (sessionID == null) {
|
if (sessionID == null) {
|
||||||
const res = await sdk.client.session.create({
|
const res = await sdk.client.session.create({ workspace: props.workspaceID })
|
||||||
workspaceID: props.workspaceID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
console.log("Creating a session failed:", res.error)
|
console.log("Creating a session failed:", res.error)
|
||||||
@@ -662,7 +662,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: {
|
model: {
|
||||||
providerID: selectedModel.providerID,
|
providerID: selectedModel.providerID,
|
||||||
modelID: selectedModel.modelID,
|
modelID: selectedModel.modelID,
|
||||||
@@ -689,7 +689,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
command: command.slice(1),
|
command: command.slice(1),
|
||||||
arguments: args,
|
arguments: args,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||||
messageID,
|
messageID,
|
||||||
variant,
|
variant,
|
||||||
@@ -706,7 +706,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
sessionID,
|
sessionID,
|
||||||
...selectedModel,
|
...selectedModel,
|
||||||
messageID,
|
messageID,
|
||||||
agent: local.agent.current().name,
|
agent: agent.name,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
variant,
|
variant,
|
||||||
parts: [
|
parts: [
|
||||||
@@ -829,7 +829,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
if (keybind.leader) return theme.border
|
if (keybind.leader) return theme.border
|
||||||
if (store.mode === "shell") return theme.primary
|
if (store.mode === "shell") return theme.primary
|
||||||
return local.agent.color(local.agent.current().name)
|
const agent = local.agent.current()
|
||||||
|
if (!agent) return theme.border
|
||||||
|
return local.agent.color(agent.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const showVariant = createMemo(() => {
|
const showVariant = createMemo(() => {
|
||||||
@@ -851,7 +853,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const spinnerDef = createMemo(() => {
|
const spinnerDef = createMemo(() => {
|
||||||
const color = local.agent.color(local.agent.current().name)
|
const agent = local.agent.current()
|
||||||
|
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||||
return {
|
return {
|
||||||
frames: createFrames({
|
frames: createFrames({
|
||||||
color,
|
color,
|
||||||
@@ -1028,6 +1031,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once we cross an async boundary below, the terminal may perform its
|
||||||
|
// default paste unless we suppress it first and handle insertion ourselves.
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
const filepath = iife(() => {
|
const filepath = iife(() => {
|
||||||
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
||||||
if (raw.startsWith("file://")) {
|
if (raw.startsWith("file://")) {
|
||||||
@@ -1041,11 +1048,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
const isUrl = /^(https?):\/\//.test(filepath)
|
const isUrl = /^(https?):\/\//.test(filepath)
|
||||||
if (!isUrl) {
|
if (!isUrl) {
|
||||||
try {
|
try {
|
||||||
const mime = Filesystem.mimeType(filepath)
|
const mime = await Filesystem.mimeType(filepath)
|
||||||
const filename = path.basename(filepath)
|
const filename = path.basename(filepath)
|
||||||
// Handle SVG as raw text content, not as base64 image
|
// Handle SVG as raw text content, not as base64 image
|
||||||
if (mime === "image/svg+xml") {
|
if (mime === "image/svg+xml") {
|
||||||
event.preventDefault()
|
|
||||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||||
if (content) {
|
if (content) {
|
||||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||||
@@ -1053,7 +1059,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mime.startsWith("image/") || mime === "application/pdf") {
|
if (mime.startsWith("image/") || mime === "application/pdf") {
|
||||||
event.preventDefault()
|
|
||||||
const content = await Filesystem.readArrayBuffer(filepath)
|
const content = await Filesystem.readArrayBuffer(filepath)
|
||||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -1075,11 +1080,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||||
!sync.data.config.experimental?.disable_paste_summary
|
!sync.data.config.experimental?.disable_paste_summary
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
|
||||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.insertText(normalizedText)
|
||||||
|
|
||||||
// Force layout update and render for the pasted content
|
// Force layout update and render for the pasted content
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// setTimeout is a workaround and needs to be addressed properly
|
// setTimeout is a workaround and needs to be addressed properly
|
||||||
@@ -1107,22 +1113,26 @@ export function Prompt(props: PromptProps) {
|
|||||||
/>
|
/>
|
||||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={highlight()}>
|
<Show when={local.agent.current()} fallback={<box height={1} />}>
|
||||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
{(agent) => (
|
||||||
</text>
|
<>
|
||||||
<Show when={store.mode === "normal"}>
|
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
|
||||||
<box flexDirection="row" gap={1}>
|
<Show when={store.mode === "normal"}>
|
||||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
<box flexDirection="row" gap={1}>
|
||||||
{local.model.parsed().model}
|
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||||
</text>
|
{local.model.parsed().model}
|
||||||
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
</text>
|
||||||
<Show when={showVariant()}>
|
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
||||||
<text fg={theme.textMuted}>·</text>
|
<Show when={showVariant()}>
|
||||||
<text>
|
<text fg={theme.textMuted}>·</text>
|
||||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
<text>
|
||||||
</text>
|
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||||
</Show>
|
</text>
|
||||||
</box>
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={hasRightContent()}>
|
<Show when={hasRightContent()}>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Context } from "effect"
|
||||||
|
|
||||||
|
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
|
||||||
|
defaultValue: () => process.cwd(),
|
||||||
|
})
|
||||||
+5
-10
@@ -2,13 +2,11 @@ import path from "path"
|
|||||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ConfigPaths } from "./paths"
|
|
||||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util"
|
|
||||||
import { Filesystem } from "@/util"
|
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
|
import { Filesystem, Log } from "@/util"
|
||||||
|
import * as ConfigPaths from "@/config/paths"
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.migrate" })
|
const log = Log.create({ service: "tui.migrate" })
|
||||||
|
|
||||||
@@ -26,9 +24,9 @@ const TuiLegacy = z
|
|||||||
.strip()
|
.strip()
|
||||||
|
|
||||||
interface MigrateInput {
|
interface MigrateInput {
|
||||||
|
cwd: string
|
||||||
directories: string[]
|
directories: string[]
|
||||||
custom?: string
|
custom?: string
|
||||||
managed: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function opencodeFiles(input: { directories: string[]; managed: string }) {
|
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
||||||
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
|
|
||||||
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
||||||
for (const dir of unique(input.directories)) {
|
for (const dir of unique(input.directories)) {
|
||||||
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||||
}
|
}
|
||||||
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
||||||
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
|
|
||||||
|
|
||||||
const existing = await Promise.all(
|
const existing = await Promise.all(
|
||||||
unique(files).map(async (file) => {
|
unique(files).map(async (file) => {
|
||||||
+4
-3
@@ -1,9 +1,10 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "."
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
|
|
||||||
const KeybindOverride = z
|
const KeybindOverride = z
|
||||||
.object(
|
.object(
|
||||||
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
|
||||||
string,
|
string,
|
||||||
z.ZodOptional<z.ZodString>
|
z.ZodOptional<z.ZodString>
|
||||||
>,
|
>,
|
||||||
@@ -30,7 +31,7 @@ export const TuiInfo = z
|
|||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
theme: z.string().optional(),
|
theme: z.string().optional(),
|
||||||
keybinds: KeybindOverride.optional(),
|
keybinds: KeybindOverride.optional(),
|
||||||
plugin: Config.PluginSpec.array().optional(),
|
plugin: ConfigPlugin.Spec.array().optional(),
|
||||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||||
})
|
})
|
||||||
.extend(TuiOptions.shape)
|
.extend(TuiOptions.shape)
|
||||||
+73
-74
@@ -1,18 +1,21 @@
|
|||||||
import { existsSync } from "fs"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import { Context, Effect, Fiber, Layer } from "effect"
|
import { Context, Effect, Fiber, Layer } from "effect"
|
||||||
import { Config } from "."
|
import { ConfigParse } from "@/config/parse"
|
||||||
import { ConfigPaths } from "./paths"
|
import * as ConfigPaths from "@/config/paths"
|
||||||
import { migrateTuiConfig } from "./tui-migrate"
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
import { TuiInfo } from "./tui-schema"
|
import { TuiInfo } from "./tui-schema"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Log } from "@/util"
|
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
import { CurrentWorkingDirectory } from "./cwd"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
|
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||||
|
import { makeRuntime } from "@/cli/effect/runtime"
|
||||||
|
import { Filesystem, Log } from "@/util"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
export namespace TuiConfig {
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
@@ -30,19 +33,19 @@ export namespace TuiConfig {
|
|||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
// Internal resolved plugin list used by runtime loading.
|
// Internal resolved plugin list used by runtime loading.
|
||||||
plugin_origins?: Config.PluginOrigin[]
|
plugin_origins?: ConfigPlugin.Origin[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Info>
|
readonly get: () => Effect.Effect<Info>
|
||||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
readonly waitForDependencies: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||||
|
|
||||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||||
if (AppFileSystem.contains(ctx.directory, file)) return "local"
|
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||||
if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
|
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +69,21 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||||
|
if (!config.plugin) return config
|
||||||
|
for (let i = 0; i < config.plugin.length; i++) {
|
||||||
|
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||||
const data = await loadFile(file)
|
const data = await loadFile(file)
|
||||||
acc.result = mergeDeep(acc.result, data)
|
acc.result = mergeDeep(acc.result, data)
|
||||||
if (!data.plugin?.length) return
|
if (!data.plugin?.length) return
|
||||||
|
|
||||||
const scope = pluginScope(file, ctx)
|
const scope = pluginScope(file, ctx)
|
||||||
const plugins = Config.deduplicatePluginOrigins([
|
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||||
...(acc.result.plugin_origins ?? []),
|
...(acc.result.plugin_origins ?? []),
|
||||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||||
])
|
])
|
||||||
@@ -80,18 +91,13 @@ export namespace TuiConfig {
|
|||||||
acc.result.plugin_origins = plugins
|
acc.result.plugin_origins = plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
async function loadState(ctx: { directory: string }) {
|
||||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||||
? []
|
const directories = await ConfigPaths.directories(ctx.directory)
|
||||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
|
||||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
|
||||||
const custom = customPath()
|
const custom = customPath()
|
||||||
const managed = Config.managedConfigDir()
|
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
|
||||||
await migrateTuiConfig({ directories, custom, managed })
|
|
||||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
||||||
? []
|
|
||||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
|
||||||
|
|
||||||
const acc: Acc = {
|
const acc: Acc = {
|
||||||
result: {},
|
result: {},
|
||||||
@@ -119,21 +125,16 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(managed)) {
|
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
|
||||||
await mergeFile(acc, file, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const keybinds = { ...acc.result.keybinds }
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||||
keybinds.terminal_suspend = "none"
|
keybinds.terminal_suspend = "none"
|
||||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
keybinds.input_undo ??= unique([
|
||||||
",",
|
"ctrl+z",
|
||||||
)
|
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||||
|
]).join(",")
|
||||||
}
|
}
|
||||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: acc.result,
|
config: acc.result,
|
||||||
@@ -144,41 +145,43 @@ export namespace TuiConfig {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cfg = yield* Config.Service
|
const directory = yield* CurrentWorkingDirectory
|
||||||
const state = yield* InstanceState.make<State>(
|
const npm = yield* Npm.Service
|
||||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
const data = yield* Effect.promise(() => loadState({ directory }))
|
||||||
const data = yield* Effect.promise(() => loadState(ctx))
|
const deps = yield* Effect.forEach(
|
||||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
data.dirs,
|
||||||
concurrency: "unbounded",
|
(dir) =>
|
||||||
})
|
npm
|
||||||
return { config: data.config, deps }
|
.install(dir, {
|
||||||
}),
|
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkScoped),
|
||||||
|
{
|
||||||
|
concurrency: "unbounded",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||||
InstanceState.useEffect(state, (s) =>
|
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Service.of({ get, waitForDependencies })
|
return Service.of({ get, waitForDependencies })
|
||||||
}),
|
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function get() {
|
|
||||||
return runPromise((svc) => svc.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForDependencies() {
|
export async function waitForDependencies() {
|
||||||
await runPromise((svc) => svc.waitForDependencies())
|
await runPromise((svc) => svc.waitForDependencies())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function get() {
|
||||||
|
return runPromise((svc) => svc.get())
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFile(filepath: string): Promise<Info> {
|
async function loadFile(filepath: string): Promise<Info> {
|
||||||
const text = await ConfigPaths.readFile(filepath)
|
const text = await ConfigPaths.readFile(filepath)
|
||||||
if (!text) return {}
|
if (!text) return {}
|
||||||
@@ -189,26 +192,22 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
return ConfigParse.load(Info, text, {
|
||||||
if (!isRecord(raw)) return {}
|
type: "path",
|
||||||
|
path: configFilepath,
|
||||||
|
missing: "empty",
|
||||||
|
normalize: (data) => {
|
||||||
|
if (!isRecord(data)) return {}
|
||||||
|
|
||||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||||
const normalized = normalize(raw)
|
return normalize(data)
|
||||||
|
},
|
||||||
const parsed = Info.safeParse(normalized)
|
})
|
||||||
if (!parsed.success) {
|
.then((data) => resolvePlugins(data, configFilepath))
|
||||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
.catch((error) => {
|
||||||
return {}
|
log.warn("invalid tui config", { path: configFilepath, error })
|
||||||
}
|
return {}
|
||||||
|
})
|
||||||
const data = parsed.data
|
|
||||||
if (data.plugin) {
|
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
|
||||||
data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Keybind } from "@/util"
|
import { Keybind } from "@/util"
|
||||||
import { pipe, mapValues } from "remeda"
|
import { pipe, mapValues } from "remeda"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createSimpleContext } from "./helper"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
@@ -6,14 +7,20 @@ import { uniqueBy } from "remeda"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { createSimpleContext } from "./helper"
|
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
import { Provider } from "@/provider"
|
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
|
|
||||||
|
export function parseModel(model: string) {
|
||||||
|
const [providerID, ...rest] = model.split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID,
|
||||||
|
modelID: rest.join("/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
name: "Local",
|
name: "Local",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const agent = iife(() => {
|
const agent = iife(() => {
|
||||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
||||||
const [agentStore, setAgentStore] = createStore<{
|
const [agentStore, setAgentStore] = createStore({
|
||||||
current: string
|
current: undefined as string | undefined,
|
||||||
}>({
|
|
||||||
current: agents()[0].name,
|
|
||||||
})
|
})
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const colors = createMemo(() => [
|
const colors = createMemo(() => [
|
||||||
@@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return agents()
|
return agents()
|
||||||
},
|
},
|
||||||
current() {
|
current() {
|
||||||
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
|
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
|
||||||
},
|
},
|
||||||
set(name: string) {
|
set(name: string) {
|
||||||
if (!agents().some((x) => x.name === name))
|
if (!agents().some((x) => x.name === name))
|
||||||
@@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const fallbackModel = createMemo(() => {
|
const fallbackModel = createMemo(() => {
|
||||||
if (args.model) {
|
if (args.model) {
|
||||||
const { providerID, modelID } = Provider.parseModel(args.model)
|
const { providerID, modelID } = parseModel(args.model)
|
||||||
if (isModelValid({ providerID, modelID })) {
|
if (isModelValid({ providerID, modelID })) {
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
@@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sync.data.config.model) {
|
if (sync.data.config.model) {
|
||||||
const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
|
const { providerID, modelID } = parseModel(sync.data.config.model)
|
||||||
if (isModelValid({ providerID, modelID })) {
|
if (isModelValid({ providerID, modelID })) {
|
||||||
return {
|
return {
|
||||||
providerID,
|
providerID,
|
||||||
@@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const a = agent.current()
|
const a = agent.current()
|
||||||
return (
|
return (
|
||||||
getFirstValidModel(
|
getFirstValidModel(
|
||||||
() => modelStore.model[a.name],
|
() => a && modelStore.model[a.name],
|
||||||
() => a.model,
|
() => a && a.model,
|
||||||
fallbackModel,
|
fallbackModel,
|
||||||
) ?? undefined
|
) ?? undefined
|
||||||
)
|
)
|
||||||
@@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (next >= recent.length) next = 0
|
if (next >= recent.length) next = 0
|
||||||
const val = recent[next]
|
const val = recent[next]
|
||||||
if (!val) return
|
if (!val) return
|
||||||
setModelStore("model", agent.current().name, { ...val })
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, { ...val })
|
||||||
},
|
},
|
||||||
cycleFavorite(direction: 1 | -1) {
|
cycleFavorite(direction: 1 | -1) {
|
||||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||||
@@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const next = favorites[index]
|
const next = favorites[index]
|
||||||
if (!next) return
|
if (!next) return
|
||||||
setModelStore("model", agent.current().name, { ...next })
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, { ...next })
|
||||||
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||||
if (uniq.length > 10) uniq.pop()
|
if (uniq.length > 10) uniq.pop()
|
||||||
setModelStore(
|
setModelStore(
|
||||||
@@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModelStore("model", agent.current().name, model)
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.name, model)
|
||||||
if (options?.recent) {
|
if (options?.recent) {
|
||||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
|
||||||
if (uniq.length > 10) uniq.pop()
|
if (uniq.length > 10) uniq.pop()
|
||||||
@@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
// Automatically update model when agent changes
|
// Automatically update model when agent changes
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const value = agent.current()
|
const value = agent.current()
|
||||||
|
if (!value) return
|
||||||
if (value.model) {
|
if (value.model) {
|
||||||
if (isModelValid(value.model))
|
if (isModelValid(value.model))
|
||||||
model.set({
|
model.set({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useExit } from "./exit"
|
|||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, createEffect, on } from "solid-js"
|
import { batch, createEffect, on } from "solid-js"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
provider: Provider[]
|
provider: Provider[]
|
||||||
provider_default: Record<string, string>
|
provider_default: Record<string, string>
|
||||||
provider_next: ProviderListResponse
|
provider_next: ProviderListResponse
|
||||||
console_state: ConsoleStateType
|
console_state: ConsoleState
|
||||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||||
agent: Agent[]
|
agent: Agent[]
|
||||||
command: Command[]
|
command: Command[]
|
||||||
@@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||||
const consoleStatePromise = sdk.client.experimental.console
|
const consoleStatePromise = sdk.client.experimental.console
|
||||||
.get({ workspace }, { throwOnError: true })
|
.get({ workspace }, { throwOnError: true })
|
||||||
.then((x) => ConsoleState.parse(x.data))
|
.then((x) => x.data)
|
||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
@@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
]
|
]
|
||||||
|
|
||||||
await Promise.all(blockingRequests)
|
await Promise.all(blockingRequests)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
const providersResponse = providersPromise.then((x) => x.data!)
|
const providersResponse = providersPromise.then((x) => x.data!)
|
||||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||||
const consoleStateResponse = consoleStatePromise
|
const consoleStateResponse = consoleStatePromise
|
||||||
@@ -474,6 +474,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (match.found) return store.session[match.index]
|
if (match.found) return store.session[match.index]
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
|
async refresh() {
|
||||||
|
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
|
const list = await sdk.client.session
|
||||||
|
.list({ start })
|
||||||
|
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||||
|
setStore("session", reconcile(list))
|
||||||
|
},
|
||||||
status(sessionID: string) {
|
status(sessionID: string) {
|
||||||
const session = result.session.get(sessionID)
|
const session = result.session.get(sessionID)
|
||||||
if (!session) return "idle"
|
if (!session) return "idle"
|
||||||
@@ -485,13 +492,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
|
console.log("YO", sessionID, fullSyncedSessions.has(sessionID))
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
const workspace = project.workspace.current()
|
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||||
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||||
sdk.client.session.todo({ sessionID, workspace }),
|
sdk.client.session.todo({ sessionID }),
|
||||||
sdk.client.session.diff({ sessionID, workspace }),
|
sdk.client.session.diff({ sessionID }),
|
||||||
])
|
])
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
||||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Layer } from "effect"
|
||||||
|
import { TuiConfig } from "./config/tui"
|
||||||
|
import { Npm } from "@opencode-ai/shared/npm"
|
||||||
|
import { Observability } from "@/effect/observability"
|
||||||
|
|
||||||
|
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||||
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
|
|||||||
import type { useSync } from "@tui/context/sync"
|
import type { useSync } from "@tui/context/sync"
|
||||||
import type { useTheme } from "@tui/context/theme"
|
import type { useTheme } from "@tui/context/theme"
|
||||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||||
import type { useKV } from "../context/kv"
|
import type { useKV } from "../context/kv"
|
||||||
import { DialogAlert } from "../ui/dialog-alert"
|
import { DialogAlert } from "../ui/dialog-alert"
|
||||||
@@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
|
|||||||
import { Prompt } from "../component/prompt"
|
import { Prompt } from "../component/prompt"
|
||||||
import { Slot as HostSlot } from "./slots"
|
import { Slot as HostSlot } from "./slots"
|
||||||
import type { useToast } from "../ui/toast"
|
import type { useToast } from "../ui/toast"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
type RouteEntry = {
|
type RouteEntry = {
|
||||||
key: symbol
|
key: symbol
|
||||||
@@ -189,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
|||||||
function appApi(): TuiPluginApi["app"] {
|
function appApi(): TuiPluginApi["app"] {
|
||||||
return {
|
return {
|
||||||
get version() {
|
get version() {
|
||||||
return Installation.VERSION
|
return InstallationVersion
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import "@opentui/solid/runtime-plugin-support"
|
// import "@opentui/solid/runtime-plugin-support"
|
||||||
import {
|
import {
|
||||||
type TuiDispose,
|
type TuiDispose,
|
||||||
type TuiPlugin,
|
type TuiPlugin,
|
||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
} from "@opencode-ai/plugin/tui"
|
} from "@opencode-ai/plugin/tui"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
import { Config } from "@/config"
|
|
||||||
import { TuiConfig } from "@/config/tui"
|
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorData, errorMessage } from "@/util/error"
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
@@ -39,16 +37,17 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||||
import { setupSlots, Slot as View } from "./slots"
|
import { setupSlots, Slot as View } from "./slots"
|
||||||
import type { HostPluginApi, HostSlots } from "./slots"
|
import type { HostPluginApi, HostSlots } from "./slots"
|
||||||
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
|
|
||||||
type PluginLoad = {
|
type PluginLoad = {
|
||||||
options: Config.PluginOptions | undefined
|
options: ConfigPlugin.Options | undefined
|
||||||
spec: string
|
spec: string
|
||||||
target: string
|
target: string
|
||||||
retry: boolean
|
retry: boolean
|
||||||
source: PluginSource | "internal"
|
source: PluginSource | "internal"
|
||||||
id: string
|
id: string
|
||||||
module: TuiPluginModule
|
module: TuiPluginModule
|
||||||
origin: Config.PluginOrigin
|
origin: ConfigPlugin.Origin
|
||||||
theme_root: string
|
theme_root: string
|
||||||
theme_files: string[]
|
theme_files: string[]
|
||||||
}
|
}
|
||||||
@@ -77,7 +76,7 @@ type RuntimeState = {
|
|||||||
slots: HostSlots
|
slots: HostSlots
|
||||||
plugins: PluginEntry[]
|
plugins: PluginEntry[]
|
||||||
plugins_by_id: Map<string, PluginEntry>
|
plugins_by_id: Map<string, PluginEntry>
|
||||||
pending: Map<string, Config.PluginOrigin>
|
pending: Map<string, ConfigPlugin.Origin>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.plugin" })
|
const log = Log.create({ service: "tui.plugin" })
|
||||||
@@ -147,7 +146,7 @@ function resolveRoot(root: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createThemeInstaller(
|
function createThemeInstaller(
|
||||||
meta: Config.PluginOrigin,
|
meta: ConfigPlugin.Origin,
|
||||||
root: string,
|
root: string,
|
||||||
spec: string,
|
spec: string,
|
||||||
plugin: PluginEntry,
|
plugin: PluginEntry,
|
||||||
@@ -590,7 +589,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
|
async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
|
||||||
return PluginLoader.loadExternal({
|
return PluginLoader.loadExternal({
|
||||||
items: list,
|
items: list,
|
||||||
kind: "tui",
|
kind: "tui",
|
||||||
@@ -745,7 +744,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
|||||||
return { plugins, ok }
|
return { plugins, ok }
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
|
function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
|
||||||
return {
|
return {
|
||||||
spec,
|
spec,
|
||||||
scope: "local",
|
scope: "local",
|
||||||
@@ -786,12 +785,11 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||||||
if (!spec) return false
|
if (!spec) return false
|
||||||
|
|
||||||
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
||||||
const next = Config.pluginSpecifier(cfg.spec)
|
const next = ConfigPlugin.pluginSpecifier(cfg.spec)
|
||||||
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
||||||
state.pending.delete(spec)
|
state.pending.delete(spec)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await Instance.provide({
|
const ready = await Instance.provide({
|
||||||
directory: state.directory,
|
directory: state.directory,
|
||||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
||||||
@@ -905,7 +903,7 @@ async function installPluginBySpec(
|
|||||||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||||
if (tui) {
|
if (tui) {
|
||||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||||
const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
|
const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
|
||||||
state.pending.set(spec, {
|
state.pending.set(spec, {
|
||||||
spec: next,
|
spec: next,
|
||||||
scope: global ? "global" : "local",
|
scope: global ? "global" : "local",
|
||||||
@@ -926,7 +924,7 @@ export namespace TuiPluginRuntime {
|
|||||||
let runtime: RuntimeState | undefined
|
let runtime: RuntimeState | undefined
|
||||||
export const Slot = View
|
export const Slot = View
|
||||||
|
|
||||||
export async function init(api: HostPluginApi) {
|
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
if (dir !== cwd) {
|
if (dir !== cwd) {
|
||||||
@@ -936,7 +934,7 @@ export namespace TuiPluginRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dir = cwd
|
dir = cwd
|
||||||
loaded = load(api)
|
loaded = load(input)
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,7 +973,8 @@ export namespace TuiPluginRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(api: Api) {
|
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||||
|
const { api, config } = input
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const slots = setupSlots(api)
|
const slots = setupSlots(api)
|
||||||
const next: RuntimeState = {
|
const next: RuntimeState = {
|
||||||
@@ -987,45 +986,45 @@ export namespace TuiPluginRuntime {
|
|||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: cwd,
|
||||||
|
fn: async () => {
|
||||||
|
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||||
|
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||||
|
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||||
|
}
|
||||||
|
|
||||||
await Instance.provide({
|
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||||
directory: cwd,
|
log.info("loading internal tui plugin", { id: item.id })
|
||||||
fn: async () => {
|
const entry = loadInternalPlugin(item)
|
||||||
const config = await TuiConfig.get()
|
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
addPluginEntry(next, {
|
||||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
id: entry.id,
|
||||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
load: entry,
|
||||||
}
|
meta,
|
||||||
|
themes: {},
|
||||||
|
plugin: entry.module.tui,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||||
log.info("loading internal tui plugin", { id: item.id })
|
await addExternalPluginEntries(next, ready)
|
||||||
const entry = loadInternalPlugin(item)
|
|
||||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
|
||||||
addPluginEntry(next, {
|
|
||||||
id: entry.id,
|
|
||||||
load: entry,
|
|
||||||
meta,
|
|
||||||
themes: {},
|
|
||||||
plugin: entry.module.tui,
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
applyInitialPluginEnabledState(next, config)
|
||||||
await addExternalPluginEntries(next, ready)
|
for (const plugin of next.plugins) {
|
||||||
|
if (!plugin.enabled) continue
|
||||||
applyInitialPluginEnabledState(next, config)
|
// Keep plugin execution sequential for deterministic side effects:
|
||||||
for (const plugin of next.plugins) {
|
// command registration order affects keybind/command precedence,
|
||||||
if (!plugin.enabled) continue
|
// route registration is last-wins when ids collide,
|
||||||
// Keep plugin execution sequential for deterministic side effects:
|
// and hook chains rely on stable plugin ordering.
|
||||||
// command registration order affects keybind/command precedence,
|
await activatePluginEntry(next, plugin, false)
|
||||||
// route registration is last-wins when ids collide,
|
}
|
||||||
// and hook chains rely on stable plugin ordering.
|
},
|
||||||
await activatePluginEntry(next, plugin, false)
|
})
|
||||||
}
|
} catch (error) {
|
||||||
},
|
|
||||||
}).catch((error) => {
|
|
||||||
fail("failed to load tui plugins", { directory: cwd, error })
|
fail("failed to load tui plugins", { directory: cwd, error })
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
|
|||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
import { strip } from "@tui/component/prompt/part"
|
import { strip } from "@tui/component/prompt/part"
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { Locale } from "@/util"
|
import { Locale } from "@/util"
|
||||||
import type { Tool } from "@/tool/tool"
|
import type { Tool } from "@/tool"
|
||||||
import type { ReadTool } from "@/tool/read"
|
import type { ReadTool } from "@/tool/read"
|
||||||
import type { WriteTool } from "@/tool/write"
|
import type { WriteTool } from "@/tool/write"
|
||||||
import { BashTool } from "@/tool/bash"
|
import { BashTool } from "@/tool/bash"
|
||||||
@@ -44,6 +44,8 @@ import type { GrepTool } from "@/tool/grep"
|
|||||||
import type { EditTool } from "@/tool/edit"
|
import type { EditTool } from "@/tool/edit"
|
||||||
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
||||||
import type { WebFetchTool } from "@/tool/webfetch"
|
import type { WebFetchTool } from "@/tool/webfetch"
|
||||||
|
import type { CodeSearchTool } from "@/tool/codesearch"
|
||||||
|
import type { WebSearchTool } from "@/tool/websearch"
|
||||||
import type { TaskTool } from "@/tool/task"
|
import type { TaskTool } from "@/tool/task"
|
||||||
import type { QuestionTool } from "@/tool/question"
|
import type { QuestionTool } from "@/tool/question"
|
||||||
import type { SkillTool } from "@/tool/skill"
|
import type { SkillTool } from "@/tool/skill"
|
||||||
@@ -66,10 +68,10 @@ import { SubagentFooter } from "./subagent-footer.tsx"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
import parsers from "../../../../../../parsers-config.ts"
|
import parsers from "../../../../../../parsers-config.ts"
|
||||||
import { Clipboard } from "../../util/clipboard"
|
import * as Clipboard from "../../util/clipboard"
|
||||||
import { Toast, useToast } from "../../ui/toast"
|
import { Toast, useToast } from "../../ui/toast"
|
||||||
import { useKV } from "../../context/kv.tsx"
|
import { useKV } from "../../context/kv.tsx"
|
||||||
import { Editor } from "../../util/editor"
|
import * as Editor from "../../util/editor"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
@@ -597,7 +599,7 @@ export function Session() {
|
|||||||
{
|
{
|
||||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||||
value: "session.toggle.conceal",
|
value: "session.toggle.conceal",
|
||||||
keybind: "messages_toggle_conceal" as any,
|
keybind: "messages_toggle_conceal",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setConceal((prev) => !prev)
|
setConceal((prev) => !prev)
|
||||||
@@ -1934,28 +1936,26 @@ function Grep(props: ToolProps<typeof GrepTool>) {
|
|||||||
|
|
||||||
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
|
<InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
|
||||||
WebFetch {(props.input as any).url}
|
WebFetch {props.input.url}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeSearch(props: ToolProps<any>) {
|
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
|
||||||
const input = props.input as any
|
const metadata = props.metadata as { results?: number }
|
||||||
const metadata = props.metadata as any
|
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
|
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
|
||||||
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WebSearch(props: ToolProps<any>) {
|
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||||
const input = props.input as any
|
const metadata = props.metadata as { numResults?: number }
|
||||||
const metadata = props.metadata as any
|
|
||||||
return (
|
return (
|
||||||
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
|
<InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
|
||||||
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1979,7 +1979,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
|
const current = createMemo(() =>
|
||||||
|
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
|
||||||
|
)
|
||||||
|
|
||||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||||
|
|
||||||
@@ -1996,8 +1998,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
|
|
||||||
if (isRunning() && tools().length > 0) {
|
if (isRunning() && tools().length > 0) {
|
||||||
// content[0] += ` · ${tools().length} toolcalls`
|
// content[0] += ` · ${tools().length} toolcalls`
|
||||||
if (current()) content.push(`↳ ${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`)
|
if (current()) {
|
||||||
else content.push(`↳ ${tools().length} toolcalls`)
|
const state = current()!.state
|
||||||
|
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
|
||||||
|
content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`)
|
||||||
|
} else content.push(`↳ ${tools().length} toolcalls`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.part.state.status === "completed") {
|
if (props.part.state.status === "completed") {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
|
|||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { useTheme } from "../../context/theme"
|
import { useTheme } from "../../context/theme"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
import { Installation } from "@/installation"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { TuiPluginRuntime } from "../../plugin"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
<span style={{ fg: theme.text }}>
|
<span style={{ fg: theme.text }}>
|
||||||
<b>Code</b>
|
<b>Code</b>
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span>{Installation.VERSION}</span>
|
<span>{InstallationVersion}</span>
|
||||||
</text>
|
</text>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
|
|||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { TuiConfig } from "@/config/tui"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
|
import { TuiConfig } from "./config/tui"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
@@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = await input(args.prompt)
|
const prompt = await input(args.prompt)
|
||||||
const config = await Instance.provide({
|
const config = await TuiConfig.get()
|
||||||
directory: cwd,
|
|
||||||
fn: () => TuiConfig.get(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const network = await resolveNetworkOptions(args)
|
const network = resolveNetworkOptionsNoConfig(args)
|
||||||
const external =
|
const external =
|
||||||
process.argv.includes("--port") ||
|
process.argv.includes("--port") ||
|
||||||
process.argv.includes("--hostname") ||
|
process.argv.includes("--hostname") ||
|
||||||
@@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
|
|||||||
process.exit(0)
|
process.exit(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// scratch
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Selection } from "@tui/util/selection"
|
import * as Selection from "@tui/util/selection"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { SplitBorder } from "../component/border"
|
import { SplitBorder } from "../component/border"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { TuiEvent } from "../event"
|
import { type TuiEvent } from "../event"
|
||||||
|
|
||||||
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
||||||
|
|
||||||
@@ -56,8 +56,7 @@ function init() {
|
|||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
show(options: ToastOptions) {
|
show(options: ToastOptions) {
|
||||||
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
const { duration, ...currentToast } = options
|
||||||
const { duration, ...currentToast } = parsedOptions
|
|
||||||
setStore("currentToast", currentToast)
|
setStore("currentToast", currentToast)
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||||
timeoutHandle = setTimeout(() => {
|
timeoutHandle = setTimeout(() => {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { platform, release } from "os"
|
import { platform, release } from "os"
|
||||||
import clipboardy from "clipboardy"
|
|
||||||
import { lazy } from "../../../../util/lazy.js"
|
import { lazy } from "../../../../util/lazy.js"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Filesystem } from "../../../../util"
|
import * as Filesystem from "../../../../util/filesystem"
|
||||||
import { Process } from "../../../../util"
|
import * as Process from "../../../../util/process"
|
||||||
import { which } from "../../../../util/which"
|
|
||||||
|
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||||
|
const getWhich = lazy(async () => {
|
||||||
|
const { which } = await import("../../../../util/which")
|
||||||
|
return which
|
||||||
|
})
|
||||||
|
|
||||||
|
const getClipboardy = lazy(async () => {
|
||||||
|
const { default: clipboardy } = await import("clipboardy")
|
||||||
|
return clipboardy
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes text to clipboard via OSC 52 escape sequence.
|
* Writes text to clipboard via OSC 52 escape sequence.
|
||||||
@@ -22,171 +31,173 @@ function writeOsc52(text: string): void {
|
|||||||
process.stdout.write(sequence)
|
process.stdout.write(sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Clipboard {
|
export interface Content {
|
||||||
export interface Content {
|
data: string
|
||||||
data: string
|
mime: string
|
||||||
mime: string
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Checks clipboard for images first, then falls back to text.
|
// Checks clipboard for images first, then falls back to text.
|
||||||
//
|
//
|
||||||
// On Windows prompt/ can call this from multiple paste signals because
|
// On Windows prompt/ can call this from multiple paste signals because
|
||||||
// terminals surface image paste differently:
|
// terminals surface image paste differently:
|
||||||
// 1. A forwarded Ctrl+V keypress
|
// 1. A forwarded Ctrl+V keypress
|
||||||
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
// 2. An empty bracketed-paste hint for image-only clipboard in Windows
|
||||||
// Terminal <1.25
|
// Terminal <1.25
|
||||||
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
|
||||||
export async function read(): Promise<Content | undefined> {
|
export async function read(): Promise<Content | undefined> {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin") {
|
if (os === "darwin") {
|
||||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||||
try {
|
try {
|
||||||
await Process.run(
|
await Process.run(
|
||||||
[
|
[
|
||||||
"osascript",
|
"osascript",
|
||||||
"-e",
|
"-e",
|
||||||
'set imageData to the clipboard as "PNGf"',
|
'set imageData to the clipboard as "PNGf"',
|
||||||
"-e",
|
"-e",
|
||||||
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
||||||
"-e",
|
"-e",
|
||||||
"set eof fileRef to 0",
|
"set eof fileRef to 0",
|
||||||
"-e",
|
"-e",
|
||||||
"write imageData to fileRef",
|
"write imageData to fileRef",
|
||||||
"-e",
|
"-e",
|
||||||
"close access fileRef",
|
"close access fileRef",
|
||||||
],
|
],
|
||||||
{ nothrow: true },
|
{ nothrow: true },
|
||||||
)
|
)
|
||||||
const buffer = await Filesystem.readBytes(tmpfile)
|
const buffer = await Filesystem.readBytes(tmpfile)
|
||||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||||
} catch {
|
} catch {
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows/WSL: probe clipboard for images via PowerShell.
|
|
||||||
// Bracketed paste can't carry image data so we read it directly.
|
|
||||||
if (os === "win32" || release().includes("WSL")) {
|
|
||||||
const script =
|
|
||||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
|
||||||
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
if (base64.text) {
|
|
||||||
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
|
||||||
if (imageBuffer.length > 0) {
|
|
||||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (os === "linux") {
|
|
||||||
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
|
||||||
if (wayland.stdout.byteLength > 0) {
|
|
||||||
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
|
||||||
}
|
|
||||||
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
if (x11.stdout.byteLength > 0) {
|
|
||||||
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await clipboardy.read().catch(() => {})
|
|
||||||
if (text) {
|
|
||||||
return { data: text, mime: "text/plain" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCopyMethod = lazy(() => {
|
// Windows/WSL: probe clipboard for images via PowerShell.
|
||||||
const os = platform()
|
// Bracketed paste can't carry image data so we read it directly.
|
||||||
|
if (os === "win32" || release().includes("WSL")) {
|
||||||
if (os === "darwin" && which("osascript")) {
|
const script =
|
||||||
console.log("clipboard: using osascript")
|
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||||
return async (text: string) => {
|
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
nothrow: true,
|
||||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
})
|
||||||
|
if (base64.text) {
|
||||||
|
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
||||||
|
if (imageBuffer.length > 0) {
|
||||||
|
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (os === "linux") {
|
if (os === "linux") {
|
||||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
||||||
console.log("clipboard: using wl-copy")
|
if (wayland.stdout.byteLength > 0) {
|
||||||
return async (text: string) => {
|
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
||||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
|
||||||
if (!proc.stdin) return
|
|
||||||
proc.stdin.write(text)
|
|
||||||
proc.stdin.end()
|
|
||||||
await proc.exited.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (which("xclip")) {
|
|
||||||
console.log("clipboard: using xclip")
|
|
||||||
return async (text: string) => {
|
|
||||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
if (!proc.stdin) return
|
|
||||||
proc.stdin.write(text)
|
|
||||||
proc.stdin.end()
|
|
||||||
await proc.exited.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (which("xsel")) {
|
|
||||||
console.log("clipboard: using xsel")
|
|
||||||
return async (text: string) => {
|
|
||||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
if (!proc.stdin) return
|
|
||||||
proc.stdin.write(text)
|
|
||||||
proc.stdin.end()
|
|
||||||
await proc.exited.catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
if (x11.stdout.byteLength > 0) {
|
||||||
|
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (os === "win32") {
|
const clipboardy = await getClipboardy()
|
||||||
console.log("clipboard: using powershell")
|
const text = await clipboardy.read().catch(() => {})
|
||||||
|
if (text) {
|
||||||
|
return { data: text, mime: "text/plain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCopyMethod = lazy(async () => {
|
||||||
|
const os = platform()
|
||||||
|
const which = await getWhich()
|
||||||
|
|
||||||
|
if (os === "darwin" && which("osascript")) {
|
||||||
|
console.log("clipboard: using osascript")
|
||||||
|
return async (text: string) => {
|
||||||
|
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||||
|
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (os === "linux") {
|
||||||
|
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||||
|
console.log("clipboard: using wl-copy")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||||
const proc = Process.spawn(
|
|
||||||
[
|
|
||||||
"powershell.exe",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!proc.stdin) return
|
if (!proc.stdin) return
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (which("xclip")) {
|
||||||
console.log("clipboard: no native support")
|
console.log("clipboard: using xclip")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
await clipboardy.write(text).catch(() => {})
|
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
if (!proc.stdin) return
|
||||||
|
proc.stdin.write(text)
|
||||||
|
proc.stdin.end()
|
||||||
|
await proc.exited.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (which("xsel")) {
|
||||||
|
console.log("clipboard: using xsel")
|
||||||
|
return async (text: string) => {
|
||||||
|
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
if (!proc.stdin) return
|
||||||
|
proc.stdin.write(text)
|
||||||
|
proc.stdin.end()
|
||||||
|
await proc.exited.catch(() => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
export async function copy(text: string): Promise<void> {
|
|
||||||
writeOsc52(text)
|
|
||||||
await getCopyMethod()(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (os === "win32") {
|
||||||
|
console.log("clipboard: using powershell")
|
||||||
|
return async (text: string) => {
|
||||||
|
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||||
|
const proc = Process.spawn(
|
||||||
|
[
|
||||||
|
"powershell.exe",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!proc.stdin) return
|
||||||
|
proc.stdin.write(text)
|
||||||
|
proc.stdin.end()
|
||||||
|
await proc.exited.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("clipboard: no native support")
|
||||||
|
return async (text: string) => {
|
||||||
|
const clipboardy = await getClipboardy()
|
||||||
|
await clipboardy.write(text).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function copy(text: string): Promise<void> {
|
||||||
|
writeOsc52(text)
|
||||||
|
const method = await getCopyMethod()
|
||||||
|
await method(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,30 @@ import { CliRenderer } from "@opentui/core"
|
|||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { Process } from "@/util"
|
import { Process } from "@/util"
|
||||||
|
|
||||||
export namespace Editor {
|
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
if (!editor) return
|
||||||
if (!editor) return
|
|
||||||
|
|
||||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||||
|
|
||||||
await Filesystem.write(filepath, opts.value)
|
await Filesystem.write(filepath, opts.value)
|
||||||
opts.renderer.suspend()
|
opts.renderer.suspend()
|
||||||
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
|
try {
|
||||||
|
const parts = editor.split(" ")
|
||||||
|
const proc = Process.spawn([...parts, filepath], {
|
||||||
|
stdin: "inherit",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
})
|
||||||
|
await proc.exited
|
||||||
|
const content = await Filesystem.readText(filepath)
|
||||||
|
return content || undefined
|
||||||
|
} finally {
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
try {
|
opts.renderer.resume()
|
||||||
const parts = editor.split(" ")
|
opts.renderer.requestRender()
|
||||||
const proc = Process.spawn([...parts, filepath], {
|
|
||||||
stdin: "inherit",
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
const content = await Filesystem.readText(filepath)
|
|
||||||
return content || undefined
|
|
||||||
} finally {
|
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
|
||||||
opts.renderer.resume()
|
|
||||||
opts.renderer.requestRender()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * as Editor from "./editor"
|
||||||
|
export * as Selection from "./selection"
|
||||||
|
export * as Sound from "./sound"
|
||||||
|
export * as Terminal from "./terminal"
|
||||||
|
export * as Clipboard from "./clipboard"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
|
||||||
import type { TuiConfig } from "@/config/tui"
|
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||||
|
|
||||||
export class CustomSpeedScroll implements ScrollAcceleration {
|
export class CustomSpeedScroll implements ScrollAcceleration {
|
||||||
constructor(private speed: number) {}
|
constructor(private speed: number) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Clipboard } from "./clipboard"
|
import * as Clipboard from "./clipboard"
|
||||||
|
|
||||||
type Toast = {
|
type Toast = {
|
||||||
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
|
||||||
@@ -10,16 +10,14 @@ type Renderer = {
|
|||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Selection {
|
export function copy(renderer: Renderer, toast: Toast): boolean {
|
||||||
export function copy(renderer: Renderer, toast: Toast): boolean {
|
const text = renderer.getSelection()?.getSelectedText()
|
||||||
const text = renderer.getSelection()?.getSelectedText()
|
if (!text) return false
|
||||||
if (!text) return false
|
|
||||||
|
|
||||||
Clipboard.copy(text)
|
Clipboard.copy(text)
|
||||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,114 +43,112 @@ function args(kind: Kind, file: string, volume: number) {
|
|||||||
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Sound {
|
let item: Player | null | undefined
|
||||||
let item: Player | null | undefined
|
let kind: Kind | null | undefined
|
||||||
let kind: Kind | null | undefined
|
let proc: Process.Child | undefined
|
||||||
let proc: Process.Child | undefined
|
let tail: ReturnType<typeof setTimeout> | undefined
|
||||||
let tail: ReturnType<typeof setTimeout> | undefined
|
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
let seq = 0
|
||||||
let seq = 0
|
let shot = 0
|
||||||
let shot = 0
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
if (item !== undefined) return item
|
if (item !== undefined) return item
|
||||||
try {
|
try {
|
||||||
item = new Player({ volume: 0.35 })
|
item = new Player({ volume: 0.35 })
|
||||||
} catch {
|
} catch {
|
||||||
item = null
|
item = null
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
async function file(path: string) {
|
|
||||||
mkdirSync(DIR, { recursive: true })
|
|
||||||
const next = join(DIR, basename(path))
|
|
||||||
const out = Bun.file(next)
|
|
||||||
if (await out.exists()) return next
|
|
||||||
await Bun.write(out, Bun.file(path))
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function asset() {
|
|
||||||
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick() {
|
|
||||||
if (kind !== undefined) return kind
|
|
||||||
kind = LIST.find((item) => which(item)) ?? null
|
|
||||||
return kind
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(file: string, volume: number) {
|
|
||||||
const kind = pick()
|
|
||||||
if (!kind) return
|
|
||||||
return Process.spawn(args(kind, file, volume), {
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
if (!tail) return
|
|
||||||
clearTimeout(tail)
|
|
||||||
tail = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function play(file: string, volume: number) {
|
|
||||||
const item = load()
|
|
||||||
if (!item) return run(file, volume)?.exited
|
|
||||||
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function start() {
|
|
||||||
stop()
|
|
||||||
const id = ++seq
|
|
||||||
void asset().then(({ hum }) => {
|
|
||||||
if (id !== seq) return
|
|
||||||
const next = run(hum, 0.24)
|
|
||||||
if (!next) return
|
|
||||||
proc = next
|
|
||||||
void next.exited.then(
|
|
||||||
() => {
|
|
||||||
if (id !== seq) return
|
|
||||||
if (proc === next) proc = undefined
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (id !== seq) return
|
|
||||||
if (proc === next) proc = undefined
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stop(delay = 0) {
|
|
||||||
seq++
|
|
||||||
clear()
|
|
||||||
if (!proc) return
|
|
||||||
const next = proc
|
|
||||||
if (delay <= 0) {
|
|
||||||
proc = undefined
|
|
||||||
void Process.stop(next).catch(() => undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tail = setTimeout(() => {
|
|
||||||
tail = undefined
|
|
||||||
if (proc === next) proc = undefined
|
|
||||||
void Process.stop(next).catch(() => undefined)
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pulse(scale = 1) {
|
|
||||||
stop(140)
|
|
||||||
const index = shot++ % FILE.length
|
|
||||||
void asset()
|
|
||||||
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispose() {
|
|
||||||
stop()
|
|
||||||
}
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
async function file(path: string) {
|
||||||
|
mkdirSync(DIR, { recursive: true })
|
||||||
|
const next = join(DIR, basename(path))
|
||||||
|
const out = Bun.file(next)
|
||||||
|
if (await out.exists()) return next
|
||||||
|
await Bun.write(out, Bun.file(path))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset() {
|
||||||
|
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick() {
|
||||||
|
if (kind !== undefined) return kind
|
||||||
|
kind = LIST.find((item) => which(item)) ?? null
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(file: string, volume: number) {
|
||||||
|
const kind = pick()
|
||||||
|
if (!kind) return
|
||||||
|
return Process.spawn(args(kind, file, volume), {
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (!tail) return
|
||||||
|
clearTimeout(tail)
|
||||||
|
tail = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(file: string, volume: number) {
|
||||||
|
const item = load()
|
||||||
|
if (!item) return run(file, volume)?.exited
|
||||||
|
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function start() {
|
||||||
|
stop()
|
||||||
|
const id = ++seq
|
||||||
|
void asset().then(({ hum }) => {
|
||||||
|
if (id !== seq) return
|
||||||
|
const next = run(hum, 0.24)
|
||||||
|
if (!next) return
|
||||||
|
proc = next
|
||||||
|
void next.exited.then(
|
||||||
|
() => {
|
||||||
|
if (id !== seq) return
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (id !== seq) return
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop(delay = 0) {
|
||||||
|
seq++
|
||||||
|
clear()
|
||||||
|
if (!proc) return
|
||||||
|
const next = proc
|
||||||
|
if (delay <= 0) {
|
||||||
|
proc = undefined
|
||||||
|
void Process.stop(next).catch(() => undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tail = setTimeout(() => {
|
||||||
|
tail = undefined
|
||||||
|
if (proc === next) proc = undefined
|
||||||
|
void Process.stop(next).catch(() => undefined)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pulse(scale = 1) {
|
||||||
|
stop(140)
|
||||||
|
const index = shot++ % FILE.length
|
||||||
|
void asset()
|
||||||
|
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispose() {
|
||||||
|
stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,135 @@
|
|||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
export namespace Terminal {
|
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
|
||||||
|
|
||||||
function parse(color: string): RGBA | null {
|
function parse(color: string): RGBA | null {
|
||||||
if (color.startsWith("rgb:")) {
|
if (color.startsWith("rgb:")) {
|
||||||
const parts = color.substring(4).split("/")
|
const parts = color.substring(4).split("/")
|
||||||
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||||
}
|
|
||||||
if (color.startsWith("#")) {
|
|
||||||
return RGBA.fromHex(color)
|
|
||||||
}
|
|
||||||
if (color.startsWith("rgb(")) {
|
|
||||||
const parts = color.substring(4, color.length - 1).split(",")
|
|
||||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
if (color.startsWith("#")) {
|
||||||
function mode(bg: RGBA | null): "dark" | "light" {
|
return RGBA.fromHex(color)
|
||||||
if (!bg) return "dark"
|
|
||||||
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
|
||||||
return luminance > 0.5 ? "light" : "dark"
|
|
||||||
}
|
}
|
||||||
|
if (color.startsWith("rgb(")) {
|
||||||
|
const parts = color.substring(4, color.length - 1).split(",")
|
||||||
|
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function mode(bg: RGBA | null): "dark" | "light" {
|
||||||
* Query terminal colors including background, foreground, and palette (0-15).
|
if (!bg) return "dark"
|
||||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||||
*
|
return luminance > 0.5 ? "light" : "dark"
|
||||||
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
|
}
|
||||||
* OSC 10/11 (foreground/background) typically work in most environments.
|
|
||||||
*
|
|
||||||
* Returns an object with background, foreground, and colors array.
|
|
||||||
* Any query that fails will be null/empty.
|
|
||||||
*/
|
|
||||||
export async function colors(): Promise<{
|
|
||||||
background: RGBA | null
|
|
||||||
foreground: RGBA | null
|
|
||||||
colors: RGBA[]
|
|
||||||
}> {
|
|
||||||
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
/**
|
||||||
let background: RGBA | null = null
|
* Query terminal colors including background, foreground, and palette (0-15).
|
||||||
let foreground: RGBA | null = null
|
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||||
const paletteColors: RGBA[] = []
|
*
|
||||||
let timeout: NodeJS.Timeout
|
* Note: OSC 4 (palette) queries may not work through tmux as responses are filtered.
|
||||||
|
* OSC 10/11 (foreground/background) typically work in most environments.
|
||||||
|
*
|
||||||
|
* Returns an object with background, foreground, and colors array.
|
||||||
|
* Any query that fails will be null/empty.
|
||||||
|
*/
|
||||||
|
export async function colors(): Promise<{
|
||||||
|
background: RGBA | null
|
||||||
|
foreground: RGBA | null
|
||||||
|
colors: RGBA[]
|
||||||
|
}> {
|
||||||
|
if (!process.stdin.isTTY) return { background: null, foreground: null, colors: [] }
|
||||||
|
|
||||||
const cleanup = () => {
|
return new Promise((resolve) => {
|
||||||
process.stdin.setRawMode(false)
|
let background: RGBA | null = null
|
||||||
process.stdin.removeListener("data", handler)
|
let foreground: RGBA | null = null
|
||||||
clearTimeout(timeout)
|
const paletteColors: RGBA[] = []
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
process.stdin.setRawMode(false)
|
||||||
|
process.stdin.removeListener("data", handler)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (data: Buffer) => {
|
||||||
|
const str = data.toString()
|
||||||
|
|
||||||
|
// Match OSC 11 (background color)
|
||||||
|
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
|
if (bgMatch) {
|
||||||
|
background = parse(bgMatch[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
// Match OSC 10 (foreground color)
|
||||||
const str = data.toString()
|
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||||
|
if (fgMatch) {
|
||||||
// Match OSC 11 (background color)
|
foreground = parse(fgMatch[1])
|
||||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
|
||||||
if (bgMatch) {
|
|
||||||
background = parse(bgMatch[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match OSC 10 (foreground color)
|
|
||||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
|
||||||
if (fgMatch) {
|
|
||||||
foreground = parse(fgMatch[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match OSC 4 (palette colors)
|
|
||||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
|
||||||
for (const match of paletteMatches) {
|
|
||||||
const index = parseInt(match[1])
|
|
||||||
const color = parse(match[2])
|
|
||||||
if (color) paletteColors[index] = color
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return immediately if we have all 16 palette colors
|
|
||||||
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
|
||||||
cleanup()
|
|
||||||
resolve({ background, foreground, colors: paletteColors })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
// Match OSC 4 (palette colors)
|
||||||
process.stdin.on("data", handler)
|
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||||
|
for (const match of paletteMatches) {
|
||||||
// Query background (OSC 11)
|
const index = parseInt(match[1])
|
||||||
process.stdout.write("\x1b]11;?\x07")
|
const color = parse(match[2])
|
||||||
// Query foreground (OSC 10)
|
if (color) paletteColors[index] = color
|
||||||
process.stdout.write("\x1b]10;?\x07")
|
|
||||||
// Query palette colors 0-15 (OSC 4)
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
process.stdout.write(`\x1b]4;${i};?\x07`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
// Return immediately if we have all 16 palette colors
|
||||||
|
if (paletteColors.filter((c) => c !== undefined).length === 16) {
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve({ background, foreground, colors: paletteColors })
|
resolve({ background, foreground, colors: paletteColors })
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
|
||||||
// needs OSC 11 and should resolve on the first background response instead of
|
|
||||||
// waiting on the full palette query used by system theme generation.
|
|
||||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|
||||||
if (!process.stdin.isTTY) return "dark"
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let timeout: NodeJS.Timeout
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
process.stdin.setRawMode(false)
|
|
||||||
process.stdin.removeListener("data", handler)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handler = (data: Buffer) => {
|
process.stdin.setRawMode(true)
|
||||||
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
process.stdin.on("data", handler)
|
||||||
if (!match) return
|
|
||||||
cleanup()
|
|
||||||
resolve(mode(parse(match[1])))
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
// Query background (OSC 11)
|
||||||
process.stdin.on("data", handler)
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
process.stdout.write("\x1b]11;?\x07")
|
// Query foreground (OSC 10)
|
||||||
|
process.stdout.write("\x1b]10;?\x07")
|
||||||
|
// Query palette colors 0-15 (OSC 4)
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
process.stdout.write(`\x1b]4;${i};?\x07`)
|
||||||
|
}
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
resolve("dark")
|
resolve({ background, foreground, colors: paletteColors })
|
||||||
}, 1000)
|
}, 1000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||||
|
// needs OSC 11 and should resolve on the first background response instead of
|
||||||
|
// waiting on the full palette query used by system theme generation.
|
||||||
|
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
|
if (!process.stdin.isTTY) return "dark"
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
process.stdin.setRawMode(false)
|
||||||
|
process.stdin.removeListener("data", handler)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (data: Buffer) => {
|
||||||
|
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||||
|
if (!match) return
|
||||||
|
cleanup()
|
||||||
|
resolve(mode(parse(match[1])))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.setRawMode(true)
|
||||||
|
process.stdin.on("data", handler)
|
||||||
|
process.stdout.write("\x1b]11;?\x07")
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
resolve("dark")
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { dlopen, ptr } from "bun:ffi"
|
import { dlopen, ptr } from "bun:ffi"
|
||||||
|
import type { ReadStream } from "node:tty"
|
||||||
|
|
||||||
const STD_INPUT_HANDLE = -10
|
const STD_INPUT_HANDLE = -10
|
||||||
const ENABLE_PROCESSED_INPUT = 0x0001
|
const ENABLE_PROCESSED_INPUT = 0x0001
|
||||||
@@ -71,7 +72,7 @@ export function win32InstallCtrlCGuard() {
|
|||||||
if (!load()) return
|
if (!load()) return
|
||||||
if (unhook) return unhook
|
if (unhook) return unhook
|
||||||
|
|
||||||
const stdin = process.stdin as any
|
const stdin = process.stdin as ReadStream
|
||||||
const original = stdin.setRawMode
|
const original = stdin.setRawMode
|
||||||
|
|
||||||
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
|
||||||
@@ -93,7 +94,7 @@ export function win32InstallCtrlCGuard() {
|
|||||||
setImmediate(enforce)
|
setImmediate(enforce)
|
||||||
}
|
}
|
||||||
|
|
||||||
let wrapped: ((mode: boolean) => unknown) | undefined
|
let wrapped: ReadStream["setRawMode"] | undefined
|
||||||
|
|
||||||
if (typeof original === "function") {
|
if (typeof original === "function") {
|
||||||
wrapped = (mode: boolean) => {
|
wrapped = (mode: boolean) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UI } from "../ui"
|
|||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
|
|
||||||
export const UpgradeCommand = {
|
export const UpgradeCommand = {
|
||||||
command: "upgrade [target]",
|
command: "upgrade [target]",
|
||||||
@@ -47,13 +48,13 @@ export const UpgradeCommand = {
|
|||||||
? args.target.replace(/^v/, "")
|
? args.target.replace(/^v/, "")
|
||||||
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
|
||||||
|
|
||||||
if (Installation.VERSION === target) {
|
if (InstallationVersion === target) {
|
||||||
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.log.info(`From ${Installation.VERSION} → ${target}`)
|
prompts.log.info(`From ${InstallationVersion} → ${target}`)
|
||||||
const spinner = prompts.spinner()
|
const spinner = prompts.spinner()
|
||||||
spinner.start("Upgrading...")
|
spinner.start("Upgrading...")
|
||||||
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Observability } from "@/effect/observability"
|
||||||
|
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
|
||||||
|
|
||||||
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
|
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
|
const getRuntime = () =>
|
||||||
|
(rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||||
|
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromiseExit(service.use(fn), options),
|
||||||
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromise(service.use(fn), options),
|
||||||
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||||
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,80 @@
|
|||||||
import { AccountServiceError, AccountTransportError } from "@/account"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { ConfigMarkdown } from "@/config/markdown"
|
|
||||||
import { errorFormat } from "@/util/error"
|
import { errorFormat } from "@/util/error"
|
||||||
import { Config } from "../config"
|
|
||||||
import { MCP } from "../mcp"
|
interface ErrorLike {
|
||||||
import { Provider } from "../provider"
|
name?: string
|
||||||
import { UI } from "./ui"
|
_tag?: string
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaggedError(error: unknown, tag: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function FormatError(input: unknown) {
|
export function FormatError(input: unknown) {
|
||||||
if (MCP.Failed.isInstance(input))
|
// MCPFailed: { name: string }
|
||||||
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
if (NamedError.hasName(input, "MCPFailed")) {
|
||||||
if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
|
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||||
return input.message
|
|
||||||
}
|
}
|
||||||
if (Provider.ModelNotFoundError.isInstance(input)) {
|
|
||||||
const { providerID, modelID, suggestions } = input.data
|
// AccountServiceError, AccountTransportError: TaggedErrorClass
|
||||||
|
if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
|
||||||
|
return (input as ErrorLike).message ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
|
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
const suggestions = data?.suggestions as string[] | undefined
|
||||||
return [
|
return [
|
||||||
`Model not found: ${providerID}/${modelID}`,
|
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
`Try: \`opencode models\` to list available models`,
|
`Try: \`opencode models\` to list available models`,
|
||||||
`Or check your config (opencode.json) provider/model names`,
|
`Or check your config (opencode.json) provider/model names`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
if (Provider.InitError.isInstance(input)) {
|
|
||||||
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
|
|
||||||
}
|
|
||||||
if (Config.JsonError.isInstance(input)) {
|
|
||||||
return (
|
|
||||||
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
|
|
||||||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
|
||||||
}
|
|
||||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
|
||||||
return input.data.message
|
|
||||||
}
|
|
||||||
if (Config.InvalidError.isInstance(input))
|
|
||||||
return [
|
|
||||||
`Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
|
|
||||||
(input.data.message ? `: ${input.data.message}` : ""),
|
|
||||||
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
if (UI.CancelledError.isInstance(input)) return ""
|
// ProviderInitError: { providerID: string }
|
||||||
|
if (NamedError.hasName(input, "ProviderInitError")) {
|
||||||
|
return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigJsonError: { path: string, message?: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigJsonError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFrontmatterError: { message: string }
|
||||||
|
if (NamedError.hasName(input, "ConfigFrontmatterError")) {
|
||||||
|
return (input as ErrorLike).data?.message ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
|
||||||
|
if (NamedError.hasName(input, "ConfigInvalidError")) {
|
||||||
|
const data = (input as ErrorLike).data
|
||||||
|
const path = data?.path
|
||||||
|
const message = data?.message
|
||||||
|
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
||||||
|
return [
|
||||||
|
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||||
|
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UICancelledError: void (no data)
|
||||||
|
if (NamedError.hasName(input, "UICancelledError")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormatUnknownError(input: unknown): string {
|
export function FormatUnknownError(input: unknown): string {
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
|
|||||||
export function withNetworkOptions<T>(yargs: Argv<T>) {
|
export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||||
return yargs.options(options)
|
return yargs.options(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
|
return resolveNetworkOptionsNoConfig(args, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
|
||||||
const portExplicitlySet = process.argv.includes("--port")
|
const portExplicitlySet = process.argv.includes("--port")
|
||||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Config } from "@/config"
|
|||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
export async function upgrade() {
|
export async function upgrade() {
|
||||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||||
@@ -15,10 +16,10 @@ export async function upgrade() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Installation.VERSION === latest) return
|
if (InstallationVersion === latest) return
|
||||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||||
|
|
||||||
const kind = Installation.getReleaseType(Installation.VERSION, latest)
|
const kind = Installation.getReleaseType(InstallationVersion, latest)
|
||||||
|
|
||||||
if (config.autoupdate === "notify" || kind !== "patch") {
|
if (config.autoupdate === "notify" || kind !== "patch") {
|
||||||
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
export * as ConfigAgent from "./agent"
|
||||||
|
|
||||||
|
import { Log } from "../util"
|
||||||
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
|
import { InvalidError } from "./error"
|
||||||
|
import * as ConfigMarkdown from "./markdown"
|
||||||
|
import { ConfigModelID } from "./model-id"
|
||||||
|
import { ConfigPermission } from "./permission"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
model: ConfigModelID.optional(),
|
||||||
|
variant: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
top_p: z.number().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
|
||||||
|
disable: z.boolean().optional(),
|
||||||
|
description: z.string().optional().describe("Description of when to use the agent"),
|
||||||
|
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
||||||
|
hidden: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
|
||||||
|
options: z.record(z.string(), z.any()).optional(),
|
||||||
|
color: z
|
||||||
|
.union([
|
||||||
|
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
|
||||||
|
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
|
||||||
|
steps: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum number of agentic iterations before forcing text-only response"),
|
||||||
|
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
|
||||||
|
permission: ConfigPermission.Info.optional(),
|
||||||
|
})
|
||||||
|
.catchall(z.any())
|
||||||
|
.transform((agent, _ctx) => {
|
||||||
|
const knownKeys = new Set([
|
||||||
|
"name",
|
||||||
|
"model",
|
||||||
|
"variant",
|
||||||
|
"prompt",
|
||||||
|
"description",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"mode",
|
||||||
|
"hidden",
|
||||||
|
"color",
|
||||||
|
"steps",
|
||||||
|
"maxSteps",
|
||||||
|
"options",
|
||||||
|
"permission",
|
||||||
|
"disable",
|
||||||
|
"tools",
|
||||||
|
])
|
||||||
|
|
||||||
|
const options: Record<string, unknown> = { ...agent.options }
|
||||||
|
for (const [key, value] of Object.entries(agent)) {
|
||||||
|
if (!knownKeys.has(key)) options[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission: ConfigPermission.Info = {}
|
||||||
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
||||||
|
const action = enabled ? "allow" : "deny"
|
||||||
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
|
permission.edit = action
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
permission[tool] = action
|
||||||
|
}
|
||||||
|
Object.assign(permission, agent.permission)
|
||||||
|
|
||||||
|
const steps = agent.steps ?? agent.maxSteps
|
||||||
|
|
||||||
|
return { ...agent, options, permission, steps } as typeof agent & {
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
permission?: ConfigPermission.Info
|
||||||
|
steps?: number
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "AgentConfig",
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export async function load(dir: string) {
|
||||||
|
const result: Record<string, Info> = {}
|
||||||
|
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})) {
|
||||||
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse agent ${item}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load agent", { agent: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
|
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
||||||
|
const name = configEntryNameFromPath(item, patterns)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
name,
|
||||||
|
...md.data,
|
||||||
|
prompt: md.content.trim(),
|
||||||
|
}
|
||||||
|
const parsed = Info.safeParse(config)
|
||||||
|
if (parsed.success) {
|
||||||
|
result[config.name] = parsed.data
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMode(dir: string) {
|
||||||
|
const result: Record<string, Info> = {}
|
||||||
|
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})) {
|
||||||
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse mode ${item}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load mode", { mode: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
name: configEntryNameFromPath(item, []),
|
||||||
|
...md.data,
|
||||||
|
prompt: md.content.trim(),
|
||||||
|
}
|
||||||
|
const parsed = Info.safeParse(config)
|
||||||
|
if (parsed.success) {
|
||||||
|
result[config.name] = {
|
||||||
|
...parsed.data,
|
||||||
|
mode: "primary" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export * as ConfigCommand from "./command"
|
||||||
|
|
||||||
|
import { Log } from "../util"
|
||||||
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
|
import { InvalidError } from "./error"
|
||||||
|
import * as ConfigMarkdown from "./markdown"
|
||||||
|
import { ConfigModelID } from "./model-id"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
export const Info = z.object({
|
||||||
|
template: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
agent: z.string().optional(),
|
||||||
|
model: ConfigModelID.optional(),
|
||||||
|
subtask: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export async function load(dir: string) {
|
||||||
|
const result: Record<string, Info> = {}
|
||||||
|
for (const item of await Glob.scan("{command,commands}/**/*.md", {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})) {
|
||||||
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse command ${item}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load command", { command: item, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!md) continue
|
||||||
|
|
||||||
|
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
|
||||||
|
const name = configEntryNameFromPath(item, patterns)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
name,
|
||||||
|
...md.data,
|
||||||
|
template: md.content.trim(),
|
||||||
|
}
|
||||||
|
const parsed = Info.safeParse(config)
|
||||||
|
if (parsed.success) {
|
||||||
|
result[config.name] = parsed.data
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user