Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13dc12fe6d | ||
|
|
c74350dc22 | ||
|
|
5fa1673341 | ||
|
|
daaa1c7e26 | ||
|
|
1fae784b81 | ||
|
|
81b7b58a5e | ||
|
|
866188a643 | ||
|
|
e6fd57165e | ||
|
|
a5d99e7a3c | ||
|
|
a92c75e5f4 | ||
|
|
826fd3350c | ||
|
|
23a2d01282 | ||
|
|
5181f9b4e1 | ||
|
|
f52ae28432 | ||
|
|
36119ff173 | ||
|
|
bb90f3bbf9 | ||
|
|
05cdb7c107 | ||
|
|
b493dabfe6 | ||
|
|
c4816f944e | ||
|
|
211136e3a8 | ||
|
|
cf0a53c501 | ||
|
|
2899984819 | ||
|
|
eafbe5c57c | ||
|
|
7b98f544ff | ||
|
|
b5aba5807c | ||
|
|
d5c4c26b4b | ||
|
|
a35b8a95c2 | ||
|
|
cded68a2e2 | ||
|
|
0068ccec35 | ||
|
|
89e8994fd1 | ||
|
|
5980b0a5ee | ||
|
|
89029a20ef | ||
|
|
ce69bd97b9 | ||
|
|
999d8651aa | ||
|
|
ed0f022502 | ||
|
|
b1307d5c2a | ||
|
|
dc16013b4f | ||
|
|
e7686dbd64 | ||
|
|
47f553f9ba | ||
|
|
d11268ece7 | ||
|
|
650a13a690 | ||
|
|
54435325b6 | ||
|
|
11fa257549 | ||
|
|
6af8ab0df2 | ||
|
|
984f5ed6eb | ||
|
|
c2061c6bbf | ||
|
|
b708e8431e | ||
|
|
9b6c397171 | ||
|
|
9b0659d4f9 | ||
|
|
f83cecaaf6 | ||
|
|
aa05b9abe5 | ||
|
|
68834cfcc3 | ||
|
|
5621373bc2 | ||
|
|
88582566bf | ||
|
|
d6e1362fee | ||
|
|
b275b8580d | ||
|
|
467be08e67 | ||
|
|
bbb422d125 | ||
|
|
b1f076558c | ||
|
|
992435aaf8 | ||
|
|
2f73e73e9d | ||
|
|
4c30a78cd9 | ||
|
|
a8c78fc005 | ||
|
|
fcb473ff64 | ||
|
|
797953c88d | ||
|
|
ce0cfb0ea5 | ||
|
|
13dfe569ef | ||
|
|
c491161c0c | ||
|
|
fde3d9133b | ||
|
|
0d582f9d3f | ||
|
|
1a59133168 | ||
|
|
803d9eb7ad | ||
|
|
a27d3c1623 | ||
|
|
551216a452 | ||
|
|
38cd3979f2 | ||
|
|
3fe602cda3 | ||
|
|
3a4b49095c | ||
|
|
ac5b395c5d | ||
|
|
8fbbca5f4b | ||
|
|
2415820ecd | ||
|
|
20103eb97b | ||
|
|
10c4ab9a3d | ||
|
|
7e39c9b950 | ||
|
|
cc063d4c32 | ||
|
|
3707e4a49c | ||
|
|
cb425ac927 | ||
|
|
0f80c827ed | ||
|
|
fffc496f41 | ||
|
|
06ae43920b | ||
|
|
e78d75a003 | ||
|
|
ec3ac0c4b0 | ||
|
|
c57c5315c1 | ||
|
|
a726530735 | ||
|
|
d9950598d0 | ||
|
|
81f0885879 | ||
|
|
65b2a10e97 | ||
|
|
7605acff65 | ||
|
|
e7f8f7fa3b | ||
|
|
72d7cb717d | ||
|
|
f0caeb9b25 | ||
|
|
76a141090e | ||
|
|
4bd5a158a5 | ||
|
|
dfaae14544 | ||
|
|
79e9baf55a | ||
|
|
9ee89f7868 | ||
|
|
67dbb3cf18 | ||
|
|
4260c40efa | ||
|
|
0bedea52b1 | ||
|
|
fbbab9d6c8 | ||
|
|
cccb907a9b | ||
|
|
ee7339f2c6 | ||
|
|
c51f3e35ca | ||
|
|
7b3bb9a761 | ||
|
|
dc38f22bd8 | ||
|
|
220e3e9a2b | ||
|
|
f135c0b5ee | ||
|
|
ebe6ea580d | ||
|
|
ee708040f6 | ||
|
|
61c4815a37 | ||
|
|
01bb54a94d | ||
|
|
f592c3846b | ||
|
|
c026e25088 | ||
|
|
8ba73bed23 | ||
|
|
4f8986aa48 | ||
|
|
9c87a144e8 | ||
|
|
5b9fa32255 | ||
|
|
f13778215a | ||
|
|
326471a25c | ||
|
|
6405e3a7b1 | ||
|
|
8afb625bab | ||
|
|
c59df636cc | ||
|
|
94878d76f8 | ||
|
|
5022895e2b | ||
|
|
54046e0b98 | ||
|
|
d2cb1613ac | ||
|
|
266fb93422 | ||
|
|
51d8219c46 | ||
|
|
d6af5a686c | ||
|
|
39342b0e75 | ||
|
|
54078c4cae | ||
|
|
c0bfccc15e | ||
|
|
53dc7b1649 | ||
|
|
635970b0a1 | ||
|
|
059b32c212 | ||
|
|
2704ad9110 | ||
|
|
06d247c709 | ||
|
|
974fa1b8b1 | ||
|
|
fb02744460 | ||
|
|
79732ab175 | ||
|
|
f6dbb2f3e0 | ||
|
|
fdd5b77bfd | ||
|
|
cde105e7a8 | ||
|
|
1291e82bb4 | ||
|
|
19d15d9ff7 | ||
|
|
4e27804160 | ||
|
|
bae80af1b4 | ||
|
|
f9aa3d77cd | ||
|
|
5d47ea0918 | ||
|
|
c03fa36257 | ||
|
|
1089fa0415 | ||
|
|
715786bbf9 | ||
|
|
218eca7c2b | ||
|
|
30fc791480 | ||
|
|
e2d161dfdd | ||
|
|
23d48a7cf1 | ||
|
|
cb18f2ef40 | ||
|
|
dbe2ff52b2 | ||
|
|
9db40996cc | ||
|
|
9f201d6370 | ||
|
|
0e86466f99 | ||
|
|
32548bcb4a | ||
|
|
86c54c5acc | ||
|
|
ae584332b3 | ||
|
|
1694c5bfe1 | ||
|
|
cdfbb26c00 | ||
|
|
610c036ef1 | ||
|
|
2638e2acfa | ||
|
|
49bbea5aed | ||
|
|
5fccdc9fc7 | ||
|
|
664b2c36e8 | ||
|
|
964474a1b1 | ||
|
|
ab15fc1575 | ||
|
|
99d392a4fb | ||
|
|
ae9a696607 | ||
|
|
bd51a0d35b | ||
|
|
8c191b10c2 | ||
|
|
cb6a9253fe | ||
|
|
23f97ac49d | ||
|
|
021ab50fb1 | ||
|
|
3fe906f517 | ||
|
|
a8d8a35cd3 | ||
|
|
9b77430d0d | ||
|
|
1045a43603 | ||
|
|
26af77cd1e | ||
|
|
25a9de301a |
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
|
|||||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
OPENCODE_DISABLE_FILETIME_CHECK
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD
|
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||||
OPENCODE_DISABLE_MODELS_FETCH
|
OPENCODE_DISABLE_MODELS_FETCH
|
||||||
OPENCODE_DISABLE_PRUNE
|
OPENCODE_DISABLE_PRUNE
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {
|
"provider": {},
|
||||||
"opencode": {
|
|
||||||
"options": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": {
|
"edit": {
|
||||||
"packages/opencode/migration/*": "deny",
|
"packages/opencode/migration/*": "deny",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||||
|
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||||
|
|
||||||
Reduce total variable count by inlining when a value is only used once.
|
Reduce total variable count by inlining when a value is only used once.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/shared": "workspace:*",
|
"@opencode-ai/shared": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -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.94",
|
"@ai-sdk/amazon-bedrock": "4.0.95",
|
||||||
"@ai-sdk/anthropic": "3.0.70",
|
"@ai-sdk/anthropic": "3.0.71",
|
||||||
"@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.102",
|
"@ai-sdk/gateway": "3.0.104",
|
||||||
"@ai-sdk/google": "3.0.63",
|
"@ai-sdk/google": "3.0.63",
|
||||||
"@ai-sdk/google-vertex": "4.0.111",
|
"@ai-sdk/google-vertex": "4.0.112",
|
||||||
"@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",
|
||||||
@@ -365,8 +365,8 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||||
"@opentui/core": "0.1.99",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "0.1.99",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.4.2",
|
"gitlab-ai-provider": "6.6.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -458,23 +458,23 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentui/core": "0.1.99",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "0.1.99",
|
"@opentui/solid": "catalog:",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.99",
|
"@opentui/core": ">=0.1.100",
|
||||||
"@opentui/solid": ">=0.1.99",
|
"@opentui/solid": ">=0.1.100",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@opencode-ai/shared",
|
"name": "@opencode-ai/shared",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -675,6 +675,8 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
|
"@opentui/core": "0.1.99",
|
||||||
|
"@opentui/solid": "0.1.99",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
@@ -690,7 +692,7 @@
|
|||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "6.0.158",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
@@ -738,7 +740,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.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/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -758,11 +760,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.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/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||||
|
|
||||||
"@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.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/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -1586,7 +1588,7 @@
|
|||||||
|
|
||||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
||||||
|
|
||||||
"@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
"@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
|
||||||
|
|
||||||
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
|
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
|
||||||
|
|
||||||
@@ -2454,7 +2456,7 @@
|
|||||||
|
|
||||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||||
|
|
||||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
@@ -2514,7 +2516,7 @@
|
|||||||
|
|
||||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
|
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||||
|
|
||||||
@@ -3312,7 +3314,7 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
@@ -5152,7 +5154,7 @@
|
|||||||
|
|
||||||
"@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.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/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||||
|
|
||||||
"@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/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=="],
|
||||||
|
|
||||||
@@ -5170,7 +5172,7 @@
|
|||||||
|
|
||||||
"@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.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/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||||
|
|
||||||
"@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/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=="],
|
||||||
|
|
||||||
@@ -5582,6 +5584,18 @@
|
|||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
|
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||||
|
|
||||||
|
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||||
@@ -5688,8 +5702,6 @@
|
|||||||
|
|
||||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||||
|
|
||||||
"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/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=="],
|
||||||
@@ -5908,7 +5920,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.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/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||||
|
|
||||||
"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-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
|
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
|
||||||
"aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
|
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
|
||||||
"aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
|
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
|
||||||
"x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
|
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -34,6 +34,8 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
|
"@opentui/core": "0.1.99",
|
||||||
|
"@opentui/solid": "0.1.99",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.48",
|
"effect": "4.0.0-beta.48",
|
||||||
"ai": "6.0.158",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { focusTerminalById } from "@/pages/session/helpers"
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
@@ -134,6 +135,7 @@ export function SessionHeader() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
@@ -151,6 +153,11 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||||
|
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
|
||||||
|
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
|
||||||
|
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
|
||||||
|
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
@@ -262,12 +269,16 @@ export function SessionHeader() {
|
|||||||
.catch((err: unknown) => showRequestError(language, err))
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
|
||||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
|
||||||
|
onMount(() => {
|
||||||
|
setCenterMount(document.getElementById("opencode-titlebar-center"))
|
||||||
|
setRightMount(document.getElementById("opencode-titlebar-right"))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={centerMount()}>
|
<Show when={search() && centerMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<Button
|
<Button
|
||||||
@@ -415,9 +426,12 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
<Show when={status()}>
|
||||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
<Show when={term()}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("command.terminal.toggle")}
|
title={language.t("command.terminal.toggle")}
|
||||||
keybind={command.keybind("terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
@@ -433,6 +447,7 @@ export function SessionHeader() {
|
|||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -451,6 +466,7 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
<Show when={tree()}>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("command.fileTree.toggle")}
|
title={language.t("command.fileTree.toggle")}
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
@@ -475,6 +491,7 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
permission.disableAutoAccept(params.id, value)
|
||||||
}
|
}
|
||||||
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
@@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AdvancedSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showFileTree.title")}
|
||||||
|
description={language.t("settings.general.row.showFileTree.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-file-tree">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showFileTree()}
|
||||||
|
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showNavigation.title")}
|
||||||
|
description={language.t("settings.general.row.showNavigation.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-navigation">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showNavigation()}
|
||||||
|
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-search">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showSearch()}
|
||||||
|
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showTerminal.title")}
|
||||||
|
description={language.t("settings.general.row.showTerminal.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-terminal">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showTerminal()}
|
||||||
|
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.showStatus.title")}
|
||||||
|
description={language.t("settings.general.row.showStatus.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-show-status">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showStatus()}
|
||||||
|
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
const AppearanceSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
@@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
console.log(import.meta.env)
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
@@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||||
|
<AdvancedSection />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
@@ -40,6 +41,7 @@ export function Titlebar() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -78,6 +80,7 @@ export function Titlebar() {
|
|||||||
const canBack = createMemo(() => history.index > 0)
|
const canBack = createMemo(() => history.index > 0)
|
||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
|
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
const next = backPath(history)
|
const next = backPath(history)
|
||||||
@@ -255,13 +258,12 @@ export function Titlebar() {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center shrink-0"
|
class="flex items-center shrink-0"
|
||||||
classList={{
|
classList={{
|
||||||
"translate-x-0": !layout.sidebar.opened(),
|
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
||||||
"-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()}>
|
<Show when={hasProjects() && nav()}>
|
||||||
<div class="flex items-center gap-0 transition-transform">
|
<div class="flex items-center gap-0 transition-transform">
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ 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 = queryClient
|
const promise = queryClient
|
||||||
.ensureQueryData({
|
.fetchQuery({
|
||||||
...loadSessionsQuery(directory),
|
...loadSessionsQuery(directory),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
loadRootSessionsWithFallback({
|
loadRootSessionsWithFallback({
|
||||||
@@ -264,7 +264,6 @@ function createGlobalSync() {
|
|||||||
children.pin(directory)
|
children.pin(directory)
|
||||||
const promise = Promise.resolve().then(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)
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ 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,7 +72,6 @@ export type State = {
|
|||||||
part: {
|
part: {
|
||||||
[messageID: string]: Part[]
|
[messageID: string]: Part[]
|
||||||
}
|
}
|
||||||
bootstrapPromise: Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VcsCache = {
|
export type VcsCache = {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface Settings {
|
|||||||
autoSave: boolean
|
autoSave: boolean
|
||||||
releaseNotes: boolean
|
releaseNotes: boolean
|
||||||
followup: "queue" | "steer"
|
followup: "queue" | "steer"
|
||||||
|
showFileTree: boolean
|
||||||
|
showNavigation: boolean
|
||||||
|
showSearch: boolean
|
||||||
|
showStatus: boolean
|
||||||
|
showTerminal: boolean
|
||||||
showReasoningSummaries: boolean
|
showReasoningSummaries: boolean
|
||||||
shellToolPartsExpanded: boolean
|
shellToolPartsExpanded: boolean
|
||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
@@ -89,6 +94,11 @@ const defaultSettings: Settings = {
|
|||||||
autoSave: true,
|
autoSave: true,
|
||||||
releaseNotes: true,
|
releaseNotes: true,
|
||||||
followup: "steer",
|
followup: "steer",
|
||||||
|
showFileTree: false,
|
||||||
|
showNavigation: false,
|
||||||
|
showSearch: false,
|
||||||
|
showStatus: false,
|
||||||
|
showTerminal: false,
|
||||||
showReasoningSummaries: false,
|
showReasoningSummaries: false,
|
||||||
shellToolPartsExpanded: false,
|
shellToolPartsExpanded: false,
|
||||||
editToolPartsExpanded: false,
|
editToolPartsExpanded: false,
|
||||||
@@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
|
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
|
||||||
|
setShowFileTree(value: boolean) {
|
||||||
|
setStore("general", "showFileTree", value)
|
||||||
|
},
|
||||||
|
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
||||||
|
setShowNavigation(value: boolean) {
|
||||||
|
setStore("general", "showNavigation", value)
|
||||||
|
},
|
||||||
|
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
|
||||||
|
setShowSearch(value: boolean) {
|
||||||
|
setStore("general", "showSearch", value)
|
||||||
|
},
|
||||||
|
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
|
||||||
|
setShowStatus(value: boolean) {
|
||||||
|
setStore("general", "showStatus", value)
|
||||||
|
},
|
||||||
|
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
||||||
|
setShowTerminal(value: boolean) {
|
||||||
|
setStore("general", "showTerminal", value)
|
||||||
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
defaultSettings.general.showReasoningSummaries,
|
defaultSettings.general.showReasoningSummaries,
|
||||||
|
|||||||
Vendored
+2
-4
@@ -1,16 +1,14 @@
|
|||||||
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"
|
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "solid-js" {
|
export declare module "solid-js" {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface Directives {
|
interface Directives {
|
||||||
sortable: true
|
sortable: true
|
||||||
|
|||||||
@@ -719,6 +719,7 @@ export const dict = {
|
|||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
|
"settings.general.section.advanced": "Advanced",
|
||||||
"settings.general.section.notifications": "System notifications",
|
"settings.general.section.notifications": "System notifications",
|
||||||
"settings.general.section.updates": "Updates",
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
@@ -741,6 +742,16 @@ export const dict = {
|
|||||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||||
"settings.general.row.followup.option.queue": "Queue",
|
"settings.general.row.followup.option.queue": "Queue",
|
||||||
"settings.general.row.followup.option.steer": "Steer",
|
"settings.general.row.followup.option.steer": "Steer",
|
||||||
|
"settings.general.row.showFileTree.title": "File tree",
|
||||||
|
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||||
|
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||||
|
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||||
|
"settings.general.row.showSearch.title": "Command palette",
|
||||||
|
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
|
||||||
|
"settings.general.row.showTerminal.title": "Terminal",
|
||||||
|
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||||
|
"settings.general.row.showStatus.title": "Server status",
|
||||||
|
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type Accessor,
|
type Accessor,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const initialDirectory = decode64(params.dir)
|
const initialDirectory = decode64(params.dir)
|
||||||
|
const location = useLocation()
|
||||||
const route = createMemo(() => {
|
const route = createMemo(() => {
|
||||||
const slug = params.dir
|
const slug = params.dir
|
||||||
if (!slug) return { slug, dir: "" }
|
if (!slug) return { slug, dir: "" }
|
||||||
@@ -2102,6 +2103,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{(project) => (
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 pl-1 py-1">
|
<div class="shrink-0 pl-1 py-1">
|
||||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||||
@@ -2227,7 +2229,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
<LocalWorkspace
|
<LocalWorkspace
|
||||||
ctx={workspaceSidebarCtx}
|
ctx={workspaceSidebarCtx}
|
||||||
project={project()!}
|
project={project()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
/>
|
/>
|
||||||
@@ -2271,7 +2273,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<SortableWorkspace
|
<SortableWorkspace
|
||||||
ctx={workspaceSidebarCtx}
|
ctx={workspaceSidebarCtx}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
project={project()!}
|
project={project()}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
/>
|
/>
|
||||||
@@ -2292,6 +2294,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -2355,14 +2358,9 @@ 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()) ?? ""}
|
{autoselecting() ?? ""}
|
||||||
<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">
|
||||||
|
|||||||
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||||
const boot = createMemo(() => open() || active())
|
const boot = createMemo(() => open() || active())
|
||||||
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||||
|
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||||
const wasBusy = createMemo((prev) => prev || busy(), false)
|
const loading = () => query.isLoading
|
||||||
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
|
|
||||||
const touch = createMediaQuery("(hover: none)")
|
const touch = createMediaQuery("(hover: none)")
|
||||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={showNew}
|
showNew={showNew}
|
||||||
loading={loading}
|
loading={() => query.isLoading && count() === 0}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
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 count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
|
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 loading = () => query.isLoading && count() === 0
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={() => false}
|
showNew={() => false}
|
||||||
loading={() => query.isLoading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -324,6 +324,7 @@ export default function Page() {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -518,26 +519,6 @@ export default function Page() {
|
|||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [vcs, setVcs] = createStore<{
|
|
||||||
diff: {
|
|
||||||
git: VcsFileDiff[]
|
|
||||||
branch: VcsFileDiff[]
|
|
||||||
}
|
|
||||||
ready: {
|
|
||||||
git: boolean
|
|
||||||
branch: boolean
|
|
||||||
}
|
|
||||||
}>({
|
|
||||||
diff: {
|
|
||||||
git: [] as VcsFileDiff[],
|
|
||||||
branch: [] as VcsFileDiff[],
|
|
||||||
},
|
|
||||||
ready: {
|
|
||||||
git: false,
|
|
||||||
branch: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [followup, setFollowup] = persisted(
|
const [followup, setFollowup] = persisted(
|
||||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -571,68 +552,6 @@ export default function Page() {
|
|||||||
let todoTimer: number | undefined
|
let todoTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
|
||||||
const vcsRun = new Map<VcsMode, number>()
|
|
||||||
|
|
||||||
const bumpVcs = (mode: VcsMode) => {
|
|
||||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
|
||||||
vcsRun.set(mode, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetVcs = (mode?: VcsMode) => {
|
|
||||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
|
||||||
list.forEach((item) => {
|
|
||||||
bumpVcs(item)
|
|
||||||
vcsTask.delete(item)
|
|
||||||
setVcs("diff", item, [])
|
|
||||||
setVcs("ready", item, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadVcs = (mode: VcsMode, force = false) => {
|
|
||||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
|
||||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
|
||||||
vcsTask.delete(mode)
|
|
||||||
setVcs("ready", mode, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = vcsTask.get(mode)
|
|
||||||
if (current) return current
|
|
||||||
|
|
||||||
const run = bumpVcs(mode)
|
|
||||||
|
|
||||||
const task = sdk.client.vcs
|
|
||||||
.diff({ mode })
|
|
||||||
.then((result) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
setVcs("diff", mode, list(result.data))
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (vcsRun.get(mode) !== run) return
|
|
||||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
|
||||||
setVcs("diff", mode, [])
|
|
||||||
setVcs("ready", mode, true)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
vcsTask.set(mode, task)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshVcs = () => {
|
|
||||||
resetVcs()
|
|
||||||
const mode = untrack(vcsMode)
|
|
||||||
if (!mode) return
|
|
||||||
if (!untrack(wantsReview)) return
|
|
||||||
void loadVcs(mode, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
createComputed((prev) => {
|
createComputed((prev) => {
|
||||||
const open = desktopReviewOpen()
|
const open = desktopReviewOpen()
|
||||||
@@ -663,21 +582,52 @@ export default function Page() {
|
|||||||
list.push("turn")
|
list.push("turn")
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
|
const wantsReview = createMemo(() =>
|
||||||
|
isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes",
|
||||||
|
)
|
||||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||||
})
|
})
|
||||||
const reviewDiffs = createMemo(() => {
|
const vcsKey = createMemo(
|
||||||
if (store.changes === "git") return list(vcs.diff.git)
|
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
|
||||||
if (store.changes === "branch") return list(vcs.diff.branch)
|
)
|
||||||
|
const vcsQuery = createQuery(() => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
const enabled = wantsReview() && sync.project?.vcs === "git"
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryKey: [...vcsKey(), mode] as const,
|
||||||
|
enabled,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
gcTime: 60 * 1000,
|
||||||
|
queryFn: mode
|
||||||
|
? () =>
|
||||||
|
sdk.client.vcs
|
||||||
|
.diff({ mode })
|
||||||
|
.then((result) => list(result.data))
|
||||||
|
.catch((error) => {
|
||||||
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
: skipToken,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||||
|
const reviewDiffs = () => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch")
|
||||||
|
// avoids suspense
|
||||||
|
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
|
||||||
return turnDiffs()
|
return turnDiffs()
|
||||||
})
|
}
|
||||||
const reviewCount = createMemo(() => reviewDiffs().length)
|
const reviewCount = () => reviewDiffs().length
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const hasReview = () => reviewCount() > 0
|
||||||
const reviewReady = createMemo(() => {
|
const reviewReady = () => {
|
||||||
if (store.changes === "git") return vcs.ready.git
|
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
|
||||||
if (store.changes === "branch") return vcs.ready.branch
|
|
||||||
return true
|
return true
|
||||||
})
|
}
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
@@ -897,27 +847,6 @@ export default function Page() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sdk.directory,
|
|
||||||
() => {
|
|
||||||
resetVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
|
||||||
(next, prev) => {
|
|
||||||
if (prev === undefined || same(next, prev)) return
|
|
||||||
refreshVcs()
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const stopVcs = sdk.event.listen((evt) => {
|
const stopVcs = sdk.event.listen((evt) => {
|
||||||
if (evt.details.type !== "file.watcher.updated") return
|
if (evt.details.type !== "file.watcher.updated") return
|
||||||
const props =
|
const props =
|
||||||
@@ -1051,13 +980,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
|
||||||
const wantsReview = createMemo(() =>
|
|
||||||
isDesktop()
|
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes",
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = changesOptions()
|
const list = changesOptions()
|
||||||
if (list.includes(store.changes)) return
|
if (list.includes(store.changes)) return
|
||||||
@@ -1066,22 +988,12 @@ export default function Page() {
|
|||||||
setStore("changes", next)
|
setStore("changes", next)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
void loadVcs(mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
const mode = vcsMode()
|
|
||||||
if (!mode) return
|
|
||||||
if (!wantsReview()) return
|
|
||||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
void loadVcs(mode, true)
|
refreshVcs()
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
@@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
|
|||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const settings = useSettings()
|
||||||
|
const sync = useSync()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
|
|||||||
const { sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const shown = createMemo(
|
||||||
|
() =>
|
||||||
|
platform.platform !== "desktop" ||
|
||||||
|
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
|
||||||
|
settings.general.showFileTree(),
|
||||||
|
)
|
||||||
|
|
||||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
|
||||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const panelWidth = createMemo(() => {
|
const panelWidth = createMemo(() => {
|
||||||
@@ -341,6 +353,7 @@ export function SessionSidePanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={shown()}>
|
||||||
<div
|
<div
|
||||||
id="file-tree-panel"
|
id="file-tree-panel"
|
||||||
aria-hidden={!fileOpen()}
|
aria-hidden={!fileOpen()}
|
||||||
@@ -398,7 +411,6 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>{empty(props.empty())}</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
@@ -433,6 +445,7 @@ export function SessionSidePanel(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
})
|
})
|
||||||
const activeFileTab = tabState.activeFileTab
|
const activeFileTab = tabState.activeFileTab
|
||||||
const closableTab = tabState.closableTab
|
const closableTab = tabState.closableTab
|
||||||
|
const shown = () =>
|
||||||
|
platform.platform !== "desktop" ||
|
||||||
|
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
|
||||||
|
settings.general.showFileTree()
|
||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
|
||||||
@@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => view().reviewPanel.toggle(),
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
}),
|
}),
|
||||||
|
...(shown()
|
||||||
|
? [
|
||||||
viewCommand({
|
viewCommand({
|
||||||
id: "fileTree.toggle",
|
id: "fileTree.toggle",
|
||||||
title: language.t("command.fileTree.toggle"),
|
title: language.t("command.fileTree.toggle"),
|
||||||
keybind: "mod+\\",
|
keybind: "mod+\\",
|
||||||
onSelect: () => layout.fileTree.toggle(),
|
onSelect: () => layout.fileTree.toggle(),
|
||||||
}),
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
viewCommand({
|
viewCommand({
|
||||||
id: "input.focus",
|
id: "input.focus",
|
||||||
title: language.t("command.input.focus"),
|
title: language.t("command.input.focus"),
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ export function persisted<T>(
|
|||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
init,
|
init,
|
||||||
Object.assign(() => ready() === true, {
|
Object.assign(() => (ready.loading ? false : ready.latest === true), {
|
||||||
promise: init instanceof Promise ? init : undefined,
|
promise: init instanceof Promise ? init : undefined,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { i18n, type Key } from "~/i18n"
|
import { i18n, type Key } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
import { localeFromRequest } from "~/lib/language"
|
||||||
|
import { createModelTpmLimiter } from "./modelTpmLimiter"
|
||||||
|
|
||||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||||
type RetryOptions = {
|
type RetryOptions = {
|
||||||
@@ -121,6 +122,8 @@ export async function handler(
|
|||||||
const authInfo = await authenticate(modelInfo, zenApiKey)
|
const authInfo = await authenticate(modelInfo, zenApiKey)
|
||||||
const billingSource = validateBilling(authInfo, modelInfo)
|
const billingSource = validateBilling(authInfo, modelInfo)
|
||||||
logger.metric({ source: billingSource })
|
logger.metric({ source: billingSource })
|
||||||
|
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
|
||||||
|
const modelTpmLimits = await modelTpmLimiter?.check()
|
||||||
|
|
||||||
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
|
||||||
const providerInfo = selectProvider(
|
const providerInfo = selectProvider(
|
||||||
@@ -133,6 +136,7 @@ export async function handler(
|
|||||||
trialProviders,
|
trialProviders,
|
||||||
retry,
|
retry,
|
||||||
stickyProvider,
|
stickyProvider,
|
||||||
|
modelTpmLimits,
|
||||||
)
|
)
|
||||||
validateModelSettings(billingSource, authInfo)
|
validateModelSettings(billingSource, authInfo)
|
||||||
updateProviderKey(authInfo, providerInfo)
|
updateProviderKey(authInfo, providerInfo)
|
||||||
@@ -229,6 +233,7 @@ export async function handler(
|
|||||||
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
const usageInfo = providerInfo.normalizeUsage(json.usage)
|
||||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
|
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -278,6 +283,7 @@ export async function handler(
|
|||||||
const usageInfo = providerInfo.normalizeUsage(usage)
|
const usageInfo = providerInfo.normalizeUsage(usage)
|
||||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
|
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
@@ -433,12 +439,16 @@ export async function handler(
|
|||||||
trialProviders: string[] | undefined,
|
trialProviders: string[] | undefined,
|
||||||
retry: RetryOptions,
|
retry: RetryOptions,
|
||||||
stickyProvider: string | undefined,
|
stickyProvider: string | undefined,
|
||||||
|
modelTpmLimits: Record<string, number> | undefined,
|
||||||
) {
|
) {
|
||||||
const modelProvider = (() => {
|
const modelProvider = (() => {
|
||||||
|
// Byok is top priority b/c if user set their own API key, we should use it
|
||||||
|
// instead of using the sticky provider for the same session
|
||||||
if (authInfo?.provider?.credentials) {
|
if (authInfo?.provider?.credentials) {
|
||||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always use the same provider for the same session
|
||||||
if (stickyProvider) {
|
if (stickyProvider) {
|
||||||
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
||||||
if (provider) return provider
|
if (provider) return provider
|
||||||
@@ -451,10 +461,20 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||||
const providers = modelInfo.providers
|
const allProviders = modelInfo.providers
|
||||||
.filter((provider) => !provider.disabled)
|
.filter((provider) => !provider.disabled)
|
||||||
|
.filter((provider) => provider.weight !== 0)
|
||||||
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
.filter((provider) => !retry.excludeProviders.includes(provider.id))
|
||||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
.filter((provider) => {
|
||||||
|
if (!provider.tpmLimit) return true
|
||||||
|
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
|
||||||
|
return usage < provider.tpmLimit * 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const topPriority = Math.min(...allProviders.map((p) => p.priority))
|
||||||
|
const providers = allProviders
|
||||||
|
.filter((p) => p.priority <= topPriority)
|
||||||
|
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
|
||||||
|
|
||||||
// Use the last 4 characters of session ID to select a provider
|
// Use the last 4 characters of session ID to select a provider
|
||||||
const identifier = sessionId.length ? sessionId : ip
|
const identifier = sessionId.length ? sessionId : ip
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
|
import { UsageInfo } from "./provider/provider"
|
||||||
|
|
||||||
|
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
|
||||||
|
const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
|
||||||
|
if (keys.length === 0) return
|
||||||
|
|
||||||
|
const yyyyMMddHHmm = new Date(Date.now())
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[^0-9]/g, "")
|
||||||
|
.substring(0, 12)
|
||||||
|
|
||||||
|
return {
|
||||||
|
check: async () => {
|
||||||
|
const data = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(ModelRateLimitTable)
|
||||||
|
.where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// convert to map of model to count
|
||||||
|
return data.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc[curr.key] = curr.count
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
track: async (id: string, model: string, usageInfo: UsageInfo) => {
|
||||||
|
const key = `${id}/${model}`
|
||||||
|
if (!keys.includes(key)) return
|
||||||
|
const usage =
|
||||||
|
usageInfo.inputTokens +
|
||||||
|
usageInfo.outputTokens +
|
||||||
|
(usageInfo.reasoningTokens ?? 0) +
|
||||||
|
(usageInfo.cacheReadTokens ?? 0) +
|
||||||
|
(usageInfo.cacheWrite5mTokens ?? 0) +
|
||||||
|
(usageInfo.cacheWrite1hTokens ?? 0)
|
||||||
|
if (usage <= 0) return
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.insert(ModelRateLimitTable)
|
||||||
|
.values({ key, interval: yyyyMMddHHmm, count: usage })
|
||||||
|
.onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `model_rate_limit` (
|
||||||
|
`key` varchar(255) NOT NULL,
|
||||||
|
`interval` varchar(40) NOT NULL,
|
||||||
|
`count` int NOT NULL,
|
||||||
|
CONSTRAINT PRIMARY KEY(`key`,`interval`)
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export namespace ZenData {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
tpmLimit: z.number().optional(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
storeModel: z.string().optional(),
|
storeModel: z.string().optional(),
|
||||||
@@ -123,10 +125,16 @@ export namespace ZenData {
|
|||||||
),
|
),
|
||||||
models: (() => {
|
models: (() => {
|
||||||
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
||||||
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
|
const providers = model.providers.map((p) => ({
|
||||||
|
...p,
|
||||||
|
priority: p.priority ?? Infinity,
|
||||||
|
weight: p.weight ?? 1,
|
||||||
|
}))
|
||||||
|
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
|
||||||
if (!composite)
|
if (!composite)
|
||||||
return {
|
return {
|
||||||
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
||||||
|
providers,
|
||||||
}
|
}
|
||||||
|
|
||||||
const weightMulti = compositeProviders[composite.id].length
|
const weightMulti = compositeProviders[composite.id].length
|
||||||
@@ -137,17 +145,16 @@ export namespace ZenData {
|
|||||||
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
||||||
return [model.trialProvider]
|
return [model.trialProvider]
|
||||||
})(),
|
})(),
|
||||||
providers: model.providers.flatMap((p) =>
|
providers: providers.flatMap((p) =>
|
||||||
p.id === composite.id
|
p.id === composite.id
|
||||||
? compositeProviders[p.id].map((sub) => ({
|
? compositeProviders[p.id].map((sub) => ({
|
||||||
...p,
|
...p,
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
weight: p.weight ?? 1,
|
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
...p,
|
...p,
|
||||||
weight: (p.weight ?? 1) * weightMulti,
|
weight: p.weight * weightMulti,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const ModelRateLimitTable = mysqlTable(
|
||||||
|
"model_rate_limit",
|
||||||
|
{
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
interval: varchar("interval", { length: 40 }).notNull(),
|
||||||
|
count: int("count").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.4.6"
|
version = "1.4.11"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.6/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -9,6 +9,63 @@
|
|||||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||||
|
|
||||||
|
# Module shape
|
||||||
|
|
||||||
|
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||||
|
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
|
||||||
|
runner. Use flat top-level exports combined with a self-reexport at the bottom
|
||||||
|
of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/foo/foo.ts
|
||||||
|
export interface Interface { ... }
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||||
|
export const layer = Layer.effect(Service, ...)
|
||||||
|
export const defaultLayer = layer.pipe(...)
|
||||||
|
|
||||||
|
export * as Foo from "./foo"
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers import the namespace projection:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Foo } from "@/foo/foo"
|
||||||
|
|
||||||
|
yield * Foo.Service
|
||||||
|
Foo.layer
|
||||||
|
Foo.defaultLayer
|
||||||
|
```
|
||||||
|
|
||||||
|
Namespace-private helpers stay as non-exported top-level declarations in the
|
||||||
|
same file — they remain inaccessible to consumers (they are not projected by
|
||||||
|
`export * as`) but are usable by the file's own code.
|
||||||
|
|
||||||
|
## When the file is an `index.ts`
|
||||||
|
|
||||||
|
If the module is `foo/index.ts` (single-namespace directory), use `"."` for
|
||||||
|
the self-reexport source rather than `"./index"`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/foo/index.ts
|
||||||
|
export const thing = ...
|
||||||
|
|
||||||
|
export * as Foo from "."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-sibling directories
|
||||||
|
|
||||||
|
For directories with several independent modules (e.g. `src/session/`,
|
||||||
|
`src/config/`), keep each sibling as its own file with its own self-reexport,
|
||||||
|
and do not add a barrel `index.ts`. Consumers import the specific sibling:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SessionRetry } from "@/session/retry"
|
||||||
|
import { SessionStatus } from "@/session/status"
|
||||||
|
```
|
||||||
|
|
||||||
|
Barrels in multi-sibling directories force every import through the barrel to
|
||||||
|
evaluate every sibling, which defeats tree-shaking and slows module load.
|
||||||
|
|
||||||
# opencode Effect rules
|
# opencode Effect rules
|
||||||
|
|
||||||
Use these rules when writing or migrating Effect code.
|
Use these rules when writing or migrating Effect code.
|
||||||
@@ -23,6 +80,10 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
|||||||
- Use `Effect.callback` for callback-based APIs.
|
- Use `Effect.callback` for callback-based APIs.
|
||||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||||
|
|
||||||
|
## Module conventions
|
||||||
|
|
||||||
|
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||||
|
|
||||||
## Schemas and errors
|
## Schemas and errors
|
||||||
|
|
||||||
- Use `Schema.Class` for multi-field data.
|
- Use `Schema.Class` for multi-field data.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.4.6",
|
"version": "1.4.11",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -79,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.94",
|
"@ai-sdk/amazon-bedrock": "4.0.95",
|
||||||
"@ai-sdk/anthropic": "3.0.70",
|
"@ai-sdk/anthropic": "3.0.71",
|
||||||
"@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.102",
|
"@ai-sdk/gateway": "3.0.104",
|
||||||
"@ai-sdk/google": "3.0.63",
|
"@ai-sdk/google": "3.0.63",
|
||||||
"@ai-sdk/google-vertex": "4.0.111",
|
"@ai-sdk/google-vertex": "4.0.112",
|
||||||
"@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",
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||||
"@opentui/core": "0.1.99",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/solid": "0.1.99",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.4.2",
|
"gitlab-ai-provider": "6.6.0",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
#!/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`)
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun script/unwrap-namespace.ts src/bus/index.ts
|
|
||||||
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
|
|
||||||
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
|
|
||||||
*
|
|
||||||
* What it does:
|
|
||||||
* 1. Reads the file and finds the `export namespace Foo { ... }` block
|
|
||||||
* (uses ast-grep for accurate AST-based boundary detection)
|
|
||||||
* 2. Removes the namespace wrapper and dedents the body
|
|
||||||
* 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
|
|
||||||
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
|
|
||||||
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
|
|
||||||
* 6. Rewrites import paths across src/, test/, and script/
|
|
||||||
* 7. Fixes sibling imports within the same directory
|
|
||||||
*
|
|
||||||
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
const dryRun = args.includes("--dry-run")
|
|
||||||
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
|
|
||||||
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
|
|
||||||
|
|
||||||
if (!filePath) {
|
|
||||||
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const absPath = path.resolve(filePath)
|
|
||||||
if (!fs.existsSync(absPath)) {
|
|
||||||
console.error(`File not found: ${absPath}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = fs.readFileSync(absPath, "utf-8")
|
|
||||||
const lines = src.split("\n")
|
|
||||||
|
|
||||||
// Use ast-grep to find the namespace boundaries accurately.
|
|
||||||
// This avoids false matches from braces in strings, templates, comments, etc.
|
|
||||||
const astResult = Bun.spawnSync(
|
|
||||||
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
|
|
||||||
{ stdout: "pipe", stderr: "pipe" },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (astResult.exitCode !== 0) {
|
|
||||||
console.error("ast-grep failed:", astResult.stderr.toString())
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
|
|
||||||
text: string
|
|
||||||
range: { start: { line: number; column: number }; end: { line: number; column: number } }
|
|
||||||
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
|
|
||||||
}>
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
console.error("No `export namespace Foo { ... }` found in file")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length > 1) {
|
|
||||||
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
|
|
||||||
console.error("Namespaces found:")
|
|
||||||
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = matches[0]
|
|
||||||
const nsName = match.metaVariables.single.NAME.text
|
|
||||||
const nsLine = match.range.start.line // 0-indexed
|
|
||||||
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
|
|
||||||
|
|
||||||
console.log(`Found: export namespace ${nsName} { ... }`)
|
|
||||||
console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
|
|
||||||
|
|
||||||
// Build the new file content:
|
|
||||||
// 1. Everything before the namespace declaration (imports, etc.)
|
|
||||||
// 2. The namespace body, dedented by one level (2 spaces)
|
|
||||||
// 3. Everything after the closing brace (rare, but possible)
|
|
||||||
const before = lines.slice(0, nsLine)
|
|
||||||
const body = lines.slice(nsLine + 1, closeLine)
|
|
||||||
const after = lines.slice(closeLine + 1)
|
|
||||||
|
|
||||||
// Dedent: remove exactly 2 leading spaces from each line
|
|
||||||
const dedented = body.map((line) => {
|
|
||||||
if (line === "") return ""
|
|
||||||
if (line.startsWith(" ")) return line.slice(2)
|
|
||||||
return line
|
|
||||||
})
|
|
||||||
|
|
||||||
let newContent = [...before, ...dedented, ...after].join("\n")
|
|
||||||
|
|
||||||
// --- Fix self-references ---
|
|
||||||
// After unwrapping, references like `Config.PermissionAction` inside the same file
|
|
||||||
// need to become just `PermissionAction`. Only fix code positions, not strings.
|
|
||||||
const exportedNames = new Set<string>()
|
|
||||||
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
|
|
||||||
for (const line of dedented) {
|
|
||||||
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
|
|
||||||
}
|
|
||||||
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
|
|
||||||
for (const line of dedented) {
|
|
||||||
for (const m of line.matchAll(reExportRegex)) {
|
|
||||||
for (const name of m[1].split(",")) {
|
|
||||||
const trimmed = name
|
|
||||||
.trim()
|
|
||||||
.split(/\s+as\s+/)
|
|
||||||
.pop()!
|
|
||||||
.trim()
|
|
||||||
if (trimmed) exportedNames.add(trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let selfRefCount = 0
|
|
||||||
if (exportedNames.size > 0) {
|
|
||||||
const fixedLines = newContent.split("\n").map((line) => {
|
|
||||||
// Split line into string-literal and code segments to avoid replacing inside strings
|
|
||||||
const segments: Array<{ text: string; isString: boolean }> = []
|
|
||||||
let i = 0
|
|
||||||
let current = ""
|
|
||||||
let inString: string | null = null
|
|
||||||
|
|
||||||
while (i < line.length) {
|
|
||||||
const ch = line[i]
|
|
||||||
if (inString) {
|
|
||||||
current += ch
|
|
||||||
if (ch === "\\" && i + 1 < line.length) {
|
|
||||||
current += line[i + 1]
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === inString) {
|
|
||||||
segments.push({ text: current, isString: true })
|
|
||||||
current = ""
|
|
||||||
inString = null
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === '"' || ch === "'" || ch === "`") {
|
|
||||||
if (current) segments.push({ text: current, isString: false })
|
|
||||||
current = ch
|
|
||||||
inString = ch
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
|
|
||||||
current += line.slice(i)
|
|
||||||
segments.push({ text: current, isString: true })
|
|
||||||
current = ""
|
|
||||||
i = line.length
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current += ch
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if (current) segments.push({ text: current, isString: !!inString })
|
|
||||||
|
|
||||||
return segments
|
|
||||||
.map((seg) => {
|
|
||||||
if (seg.isString) return seg.text
|
|
||||||
let result = seg.text
|
|
||||||
for (const name of exportedNames) {
|
|
||||||
const pattern = `${nsName}.${name}`
|
|
||||||
while (result.includes(pattern)) {
|
|
||||||
const idx = result.indexOf(pattern)
|
|
||||||
const charBefore = idx > 0 ? result[idx - 1] : " "
|
|
||||||
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
|
|
||||||
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
|
|
||||||
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
|
|
||||||
selfRefCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
})
|
|
||||||
newContent = fixedLines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out file naming
|
|
||||||
const dir = path.dirname(absPath)
|
|
||||||
const basename = path.basename(absPath, ".ts")
|
|
||||||
const isIndex = basename === "index"
|
|
||||||
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
|
|
||||||
const implFile = path.join(dir, `${implName}.ts`)
|
|
||||||
const indexFile = path.join(dir, "index.ts")
|
|
||||||
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
|
|
||||||
|
|
||||||
console.log("")
|
|
||||||
if (isIndex) {
|
|
||||||
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
|
|
||||||
} else {
|
|
||||||
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
|
|
||||||
}
|
|
||||||
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
|
|
||||||
console.log("")
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log("--- DRY RUN ---")
|
|
||||||
console.log("")
|
|
||||||
console.log(`=== ${implName}.ts (first 30 lines) ===`)
|
|
||||||
newContent
|
|
||||||
.split("\n")
|
|
||||||
.slice(0, 30)
|
|
||||||
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
|
|
||||||
console.log(" ...")
|
|
||||||
console.log("")
|
|
||||||
console.log(`=== index.ts ===`)
|
|
||||||
console.log(` ${barrelLine.trim()}`)
|
|
||||||
console.log("")
|
|
||||||
if (!isIndex) {
|
|
||||||
const relDir = path.relative(path.resolve("src"), dir)
|
|
||||||
console.log(`=== Import rewrites (would apply) ===`)
|
|
||||||
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
|
|
||||||
} else {
|
|
||||||
console.log("No import rewrites needed (was index.ts)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isIndex) {
|
|
||||||
fs.writeFileSync(implFile, newContent)
|
|
||||||
fs.writeFileSync(indexFile, barrelLine)
|
|
||||||
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
|
|
||||||
console.log(`Wrote index.ts (barrel)`)
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(absPath, newContent)
|
|
||||||
if (fs.existsSync(indexFile)) {
|
|
||||||
const existing = fs.readFileSync(indexFile, "utf-8")
|
|
||||||
if (!existing.includes(`export * as ${nsName}`)) {
|
|
||||||
fs.appendFileSync(indexFile, barrelLine)
|
|
||||||
console.log(`Appended to existing index.ts`)
|
|
||||||
} else {
|
|
||||||
console.log(`index.ts already has ${nsName} export`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(indexFile, barrelLine)
|
|
||||||
console.log(`Wrote index.ts (barrel)`)
|
|
||||||
}
|
|
||||||
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Rewrite import paths across src/, test/, script/ ---
|
|
||||||
const relDir = path.relative(path.resolve("src"), dir)
|
|
||||||
if (!isIndex) {
|
|
||||||
const oldTail = `${relDir}/${basename}`
|
|
||||||
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
|
|
||||||
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
const filesToRewrite = rgResult.stdout
|
|
||||||
.toString()
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter((f) => f.length > 0)
|
|
||||||
|
|
||||||
if (filesToRewrite.length > 0) {
|
|
||||||
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
|
|
||||||
for (const file of filesToRewrite) {
|
|
||||||
const content = fs.readFileSync(file, "utf-8")
|
|
||||||
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
|
|
||||||
}
|
|
||||||
console.log(` Done: ${oldTail}" → ${relDir}"`)
|
|
||||||
} else {
|
|
||||||
console.log("\nNo import rewrites needed")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("\nNo import rewrites needed (was index.ts)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fix sibling imports within the same directory ---
|
|
||||||
const siblingFiles = fs.readdirSync(dir).filter((f) => {
|
|
||||||
if (!f.endsWith(".ts")) return false
|
|
||||||
if (f === "index.ts" || f === `${implName}.ts`) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
let siblingFixCount = 0
|
|
||||||
for (const sibFile of siblingFiles) {
|
|
||||||
const sibPath = path.join(dir, sibFile)
|
|
||||||
const content = fs.readFileSync(sibPath, "utf-8")
|
|
||||||
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
|
|
||||||
if (pattern.test(content)) {
|
|
||||||
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
|
|
||||||
siblingFixCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (siblingFixCount > 0) {
|
|
||||||
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("")
|
|
||||||
console.log("=== Verify ===")
|
|
||||||
console.log("")
|
|
||||||
console.log("bunx --bun tsgo --noEmit # typecheck")
|
|
||||||
console.log("bun run test # run tests")
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
# Facade removal checklist
|
# Facade removal checklist
|
||||||
|
|
||||||
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
|
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
|
||||||
|
|
||||||
As of 2026-04-13, latest `origin/dev`:
|
Current status on this branch:
|
||||||
|
|
||||||
- `src/` still has 15 `makeRuntime(...)` call sites.
|
- `src/` has 5 `makeRuntime(...)` call sites total.
|
||||||
- 13 of those are still in scope for facade removal.
|
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
|
||||||
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
|
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
|
||||||
|
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
|
||||||
|
|
||||||
Recent progress:
|
Recent progress:
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ Recent progress:
|
|||||||
|
|
||||||
## Priority hotspots
|
## Priority hotspots
|
||||||
|
|
||||||
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
|
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
|
||||||
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
|
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
|
||||||
|
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
|
||||||
|
|
||||||
## Completed Batches
|
## Completed Batches
|
||||||
|
|
||||||
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
|
|||||||
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
||||||
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
||||||
|
|
||||||
## Next batch
|
## Remaining work
|
||||||
|
|
||||||
Recommended next five, in order:
|
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
|
||||||
|
|
||||||
1. `src/permission/index.ts`
|
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
|
||||||
2. `src/agent/agent.ts`
|
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
|
||||||
3. `src/session/summary.ts`
|
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
|
||||||
4. `src/session/revert.ts`
|
|
||||||
5. `src/mcp/auth.ts`
|
|
||||||
|
|
||||||
Why this batch:
|
|
||||||
|
|
||||||
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
|
|
||||||
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
|
|
||||||
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
|
|
||||||
|
|
||||||
After that batch, the expected follow-up is the main session cluster:
|
|
||||||
|
|
||||||
1. `src/session/index.ts`
|
|
||||||
2. `src/session/prompt.ts`
|
|
||||||
3. `src/session/compaction.ts`
|
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
|
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
|
||||||
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
|
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
|
||||||
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
|
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
|
||||||
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
|
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
|
||||||
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
|
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
|
||||||
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
|
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
|
||||||
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
|
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
|
||||||
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
|
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
|
||||||
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
|
- [x] `src/file/index.ts` (`File`) - facades removed and merged
|
||||||
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
|
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
|
||||||
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
|
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
|
||||||
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
|
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
|
||||||
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
|
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
|
||||||
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
|
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
|
||||||
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
|
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
|
||||||
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
|
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
|
||||||
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
|
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
|
||||||
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
|
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
|
||||||
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
|
|
||||||
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
|
|
||||||
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
|
|
||||||
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
|
|
||||||
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
|
|
||||||
|
|
||||||
## Excluded `makeRuntime(...)` sites
|
## Excluded `makeRuntime(...)` sites
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
|
|||||||
|
|
||||||
### Mixed handler styles
|
### Mixed handler styles
|
||||||
|
|
||||||
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
|
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
|
||||||
|
|
||||||
### Non-JSON routes
|
### Non-JSON routes
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
|
|||||||
|
|
||||||
### 1. Finish the prerequisites first
|
### 1. Finish the prerequisites first
|
||||||
|
|
||||||
- continue route-handler effectification in `server/instance/*.ts`
|
- continue route-handler effectification in `server/routes/instance/*.ts`
|
||||||
- continue schema migration toward Effect Schema-first DTOs and errors
|
- continue schema migration toward Effect Schema-first DTOs and errors
|
||||||
- keep removing service facades
|
- keep removing service facades
|
||||||
|
|
||||||
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
|
|||||||
|
|
||||||
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
|
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
|
||||||
|
|
||||||
- `server/instance/question.ts`
|
- `server/routes/instance/question.ts`
|
||||||
- `server/instance/provider.ts`
|
- `server/routes/instance/provider.ts`
|
||||||
- `server/instance/permission.ts`
|
- `server/routes/instance/permission.ts`
|
||||||
|
|
||||||
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
||||||
|
|
||||||
@@ -155,9 +155,9 @@ This gives:
|
|||||||
|
|
||||||
As each route group is ported to `HttpApi`:
|
As each route group is ported to `HttpApi`:
|
||||||
|
|
||||||
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
|
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
|
||||||
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
|
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
||||||
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
|
||||||
4. verify SDK output is unchanged
|
4. verify SDK output is unchanged
|
||||||
|
|
||||||
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||||
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
|
|||||||
|
|
||||||
SDK shape rule:
|
SDK shape rule:
|
||||||
|
|
||||||
- every schema migration must preserve the generated SDK output byte-for-byte
|
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
|
||||||
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
|
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||||
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
|
|
||||||
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
### Schema.Class vs Schema.Struct
|
||||||
|
|
||||||
|
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
|
||||||
|
|
||||||
|
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Info = Schema.Struct({ ... }).pipe(
|
||||||
|
withStatics((s) => ({ zod: zod(s) })),
|
||||||
|
)
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use each:
|
||||||
|
|
||||||
|
- Use **Schema.Class** when:
|
||||||
|
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
|
||||||
|
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
|
||||||
|
- Use **Schema.Struct** when:
|
||||||
|
- the type is only used as a nested field inside another named schema
|
||||||
|
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
|
||||||
|
|
||||||
|
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
|
||||||
|
|
||||||
|
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
|
||||||
|
```
|
||||||
|
|
||||||
Temporary exception:
|
Temporary exception:
|
||||||
|
|
||||||
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
|
|||||||
3. Apply the schema migration ordering above so those types are Effect Schema-first.
|
3. Apply the schema migration ordering above so those types are Effect Schema-first.
|
||||||
4. Define the `HttpApi` contract separately from the handlers.
|
4. Define the `HttpApi` contract separately from the handlers.
|
||||||
5. Implement handlers by yielding the existing service from context.
|
5. Implement handlers by yielding the existing service from context.
|
||||||
6. Mount the new surface in parallel under an experimental prefix.
|
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
|
||||||
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
|
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
|
||||||
8. Add one end-to-end test and one OpenAPI-focused test.
|
8. Add one end-to-end test and one OpenAPI-focused test.
|
||||||
9. Compare ergonomics before migrating the next endpoint.
|
9. Compare ergonomics before migrating the next endpoint.
|
||||||
@@ -250,20 +286,20 @@ Placement rule:
|
|||||||
- keep `HttpApi` code under `src/server`, not `src/effect`
|
- keep `HttpApi` code under `src/server`, not `src/effect`
|
||||||
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
|
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
|
||||||
- place each `HttpApi` slice next to the HTTP boundary it serves
|
- place each `HttpApi` slice next to the HTTP boundary it serves
|
||||||
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
|
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
|
||||||
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
|
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
|
||||||
|
|
||||||
Suggested file layout for a repeatable spike:
|
Suggested file layout for a repeatable spike:
|
||||||
|
|
||||||
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
|
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
|
||||||
- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
|
- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
|
||||||
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
|
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
|
||||||
|
|
||||||
Suggested responsibilities:
|
Suggested responsibilities:
|
||||||
|
|
||||||
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
|
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
|
||||||
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
|
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
|
||||||
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
|
- tests should verify the bridged routes through the normal server surface
|
||||||
|
|
||||||
## Example migration shape
|
## Example migration shape
|
||||||
|
|
||||||
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
|
|||||||
- keep handler bodies thin
|
- keep handler bodies thin
|
||||||
- keep transport mapping at the HTTP boundary only
|
- keep transport mapping at the HTTP boundary only
|
||||||
|
|
||||||
### 3. Standalone server
|
### 3. Bridged server
|
||||||
|
|
||||||
- the Effect HTTP server is self-contained in `httpapi/server.ts`
|
- the Effect HTTP layer is composed in `httpapi/server.ts`
|
||||||
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
|
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
|
||||||
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
|
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||||
- each route group exposes its own OpenAPI doc endpoint
|
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
|
||||||
|
|
||||||
### 4. Verification
|
### 4. Verification
|
||||||
|
|
||||||
- seed real state through the existing service
|
- seed real state through the existing service
|
||||||
- call the experimental endpoints
|
- call the bridged endpoints with the flag enabled
|
||||||
- assert that the service behavior is unchanged
|
- assert that the service behavior is unchanged
|
||||||
- assert that the generated OpenAPI contains the migrated paths and schemas
|
- assert that the generated OpenAPI contains the migrated paths and schemas
|
||||||
|
|
||||||
## Boundary composition
|
## Boundary composition
|
||||||
|
|
||||||
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
|
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
||||||
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
|
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
|
||||||
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
|
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
|
||||||
|
|
||||||
### Instance and workspace lookup
|
### Instance and workspace lookup
|
||||||
|
|
||||||
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
||||||
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
|
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
|
||||||
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
|
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
|
||||||
|
|
||||||
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
|
|||||||
|
|
||||||
The first slice is successful if:
|
The first slice is successful if:
|
||||||
|
|
||||||
- the standalone Effect server starts and serves the endpoints independently of the Hono server
|
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
|
||||||
- the handlers reuse the existing Effect service
|
- the handlers reuse the existing Effect service
|
||||||
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
||||||
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
||||||
@@ -365,17 +401,16 @@ Current instance route inventory:
|
|||||||
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
||||||
- `permission` - `bridged`
|
- `permission` - `bridged`
|
||||||
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
||||||
- `provider` - `bridged` (partial)
|
- `provider` - `bridged`
|
||||||
bridged endpoint: `GET /provider/auth`
|
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
|
||||||
not yet ported: `GET /provider`, OAuth mutations
|
- `config` - `bridged` (partial)
|
||||||
- `config` - `next`
|
bridged endpoint: `GET /config/providers`
|
||||||
best next endpoint: `GET /config/providers`
|
|
||||||
later endpoint: `GET /config`
|
later endpoint: `GET /config`
|
||||||
defer `PATCH /config` for now
|
defer `PATCH /config` for now
|
||||||
- `project` - `later`
|
- `project` - `bridged` (partial)
|
||||||
best small reads: `GET /project`, `GET /project/current`
|
bridged endpoints: `GET /project`, `GET /project/current`
|
||||||
defer git-init mutation first
|
defer git-init mutation first
|
||||||
- `workspace` - `later`
|
- `workspace` - `next`
|
||||||
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
||||||
defer create/remove mutations first
|
defer create/remove mutations first
|
||||||
- `file` - `later`
|
- `file` - `later`
|
||||||
@@ -393,12 +428,12 @@ Current instance route inventory:
|
|||||||
- `tui` - `defer`
|
- `tui` - `defer`
|
||||||
queue-style UI bridge, weak early `HttpApi` fit
|
queue-style UI bridge, weak early `HttpApi` fit
|
||||||
|
|
||||||
Recommended near-term sequence after the first spike:
|
Recommended near-term sequence:
|
||||||
|
|
||||||
1. `provider` auth read endpoint
|
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
|
||||||
2. `config` providers read endpoint
|
2. `config` full read endpoint (`GET /config`)
|
||||||
3. `project` read endpoints
|
3. `file` JSON read endpoints
|
||||||
4. `workspace` read endpoints
|
4. `mcp` JSON read endpoints
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
|
|||||||
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||||
- [x] verify OTEL spans and HTTP logs flow to motel
|
- [x] verify OTEL spans and HTTP logs flow to motel
|
||||||
- [x] bridge question, permission, and provider auth routes
|
- [x] bridge question, permission, and provider auth routes
|
||||||
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||||
- [ ] port `config` read endpoints
|
- [x] port `config` providers read endpoint
|
||||||
|
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
|
||||||
|
- [ ] port `workspace` read endpoints
|
||||||
|
- [ ] port `GET /config` full read endpoint
|
||||||
|
- [ ] port `file` JSON read endpoints
|
||||||
- [ ] decide when to remove the flag and make Effect routes the default
|
- [ ] decide when to remove the flag and make Effect routes the default
|
||||||
|
|
||||||
## Rule of thumb
|
## Rule of thumb
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
|
|||||||
- `Instance.reload(...)`
|
- `Instance.reload(...)`
|
||||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||||
|
|
||||||
Current total: `54` files in `packages/opencode/src`.
|
Current total: `56` files in `packages/opencode/src`.
|
||||||
|
|
||||||
### Core bridge and plumbing
|
### Core bridge and plumbing
|
||||||
|
|
||||||
@@ -177,13 +177,13 @@ Migration rule:
|
|||||||
|
|
||||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||||
|
|
||||||
- `src/server/instance/middleware.ts`
|
- `src/server/routes/instance/middleware.ts`
|
||||||
- `src/server/instance/index.ts`
|
- `src/server/routes/instance/index.ts`
|
||||||
- `src/server/instance/project.ts`
|
- `src/server/routes/instance/project.ts`
|
||||||
- `src/server/instance/workspace.ts`
|
- `src/server/routes/control/workspace.ts`
|
||||||
- `src/server/instance/file.ts`
|
- `src/server/routes/instance/file.ts`
|
||||||
- `src/server/instance/experimental.ts`
|
- `src/server/routes/instance/experimental.ts`
|
||||||
- `src/server/instance/global.ts`
|
- `src/server/routes/global.ts`
|
||||||
|
|
||||||
Migration rule:
|
Migration rule:
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ Migration rule:
|
|||||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||||
|
|
||||||
- `src/agent/agent.ts`
|
- `src/agent/agent.ts`
|
||||||
- `src/config/tui-migrate.ts`
|
- `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||||
- `src/file/index.ts`
|
- `src/file/index.ts`
|
||||||
- `src/file/watcher.ts`
|
- `src/file/watcher.ts`
|
||||||
- `src/format/formatter.ts`
|
- `src/format/formatter.ts`
|
||||||
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
|
|||||||
- `src/project/vcs.ts`
|
- `src/project/vcs.ts`
|
||||||
- `src/provider/provider.ts`
|
- `src/provider/provider.ts`
|
||||||
- `src/pty/index.ts`
|
- `src/pty/index.ts`
|
||||||
- `src/session/index.ts`
|
- `src/session/session.ts`
|
||||||
- `src/session/instruction.ts`
|
- `src/session/instruction.ts`
|
||||||
- `src/session/llm.ts`
|
- `src/session/llm.ts`
|
||||||
- `src/session/system.ts`
|
- `src/session/system.ts`
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
|||||||
|
|
||||||
## Config / TUI
|
## Config / TUI
|
||||||
|
|
||||||
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
|
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
|
||||||
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
|
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
|
||||||
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
|
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
|
||||||
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
|
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
|
||||||
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
|
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
|
||||||
|
|
||||||
## ConfigPaths
|
## ConfigPaths
|
||||||
|
|
||||||
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
|
|||||||
- `readFile(...)`
|
- `readFile(...)`
|
||||||
- `parseText(...)`
|
- `parseText(...)`
|
||||||
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
|
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
|
||||||
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||||
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
||||||
|
|
||||||
## Instance cleanup
|
## Instance cleanup
|
||||||
|
|
||||||
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
|
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
|
||||||
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
|
|
||||||
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
|
|||||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||||
|
|
||||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||||
|
|
||||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||||
|
|
||||||
@@ -19,20 +19,18 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
|
|||||||
|
|
||||||
## Service shape
|
## Service shape
|
||||||
|
|
||||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace Foo {
|
export interface Interface {
|
||||||
export interface Interface {
|
|
||||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// For instance-scoped services:
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||||
)
|
)
|
||||||
@@ -44,28 +42,20 @@ export namespace Foo {
|
|||||||
|
|
||||||
return Service.of({ get })
|
return Service.of({ get })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optional: wire dependencies
|
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
|
||||||
|
|
||||||
// Per-service runtime (inside the namespace)
|
export * as Foo from "."
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
// Async facade functions
|
|
||||||
export async function get(id: FooID) {
|
|
||||||
return runPromise((svc) => svc.get(id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
|
||||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
- Use `Effect.fn("Foo.method")` for Effect methods
|
||||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
|
||||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
|
||||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
|
||||||
|
|
||||||
## Schema → Zod interop
|
## Schema → Zod interop
|
||||||
|
|
||||||
@@ -195,7 +185,6 @@ This checklist is only about the service shape migration. Many of these services
|
|||||||
- [x] `Config` — `config/config.ts`
|
- [x] `Config` — `config/config.ts`
|
||||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||||
- [x] `File` — `file/index.ts`
|
- [x] `File` — `file/index.ts`
|
||||||
- [x] `FileTime` — `file/time.ts`
|
|
||||||
- [x] `FileWatcher` — `file/watcher.ts`
|
- [x] `FileWatcher` — `file/watcher.ts`
|
||||||
- [x] `Format` — `format/index.ts`
|
- [x] `Format` — `format/index.ts`
|
||||||
- [x] `Installation` — `installation/index.ts`
|
- [x] `Installation` — `installation/index.ts`
|
||||||
@@ -267,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
|
|||||||
|
|
||||||
## Destroying the facades
|
## Destroying the facades
|
||||||
|
|
||||||
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
|
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
|
||||||
|
|
||||||
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||||
|
|
||||||
@@ -298,12 +287,11 @@ For each service, the migration is roughly:
|
|||||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
|
||||||
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
|
||||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||||
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
|
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
|
||||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||||
|
|
||||||
## Route handler effectification
|
## Route handler effectification
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# Namespace → self-reexport migration
|
|
||||||
|
|
||||||
Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
|
|
||||||
single self-reexport line at the bottom of the same file:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export * as Foo from "./foo"
|
|
||||||
```
|
|
||||||
|
|
||||||
No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
|
|
||||||
exact same `import { Foo } from "../foo/foo"` ergonomics.
|
|
||||||
|
|
||||||
## Why this pattern
|
|
||||||
|
|
||||||
We tested three options against Bun, esbuild, Rollup (what Vite uses under the
|
|
||||||
hood), Bun's runtime, and Node's native TypeScript runner.
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
├── { Provider } from provider/provider.ts (~1700 lines)
|
|
||||||
│ ├── 20+ @ai-sdk/* packages
|
|
||||||
│ ├── @aws-sdk/credential-providers
|
|
||||||
│ ├── google-auth-library
|
|
||||||
│ └── more
|
|
||||||
├── { Config } from config/config.ts (~1600 lines)
|
|
||||||
└── { MCP } from mcp/mcp.ts (~900 lines)
|
|
||||||
```
|
|
||||||
|
|
||||||
All of that currently gets pulled in just to do `.isInstance()` on a handful
|
|
||||||
of error classes. The namespace IIFE shape is the main reason bundlers cannot
|
|
||||||
strip the unused parts. Self-reexport + flat ESM fixes it.
|
|
||||||
|
|
||||||
## Automation
|
|
||||||
|
|
||||||
From `packages/opencode`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun script/unwrap-namespace.ts <file> [--dry-run]
|
|
||||||
```
|
|
||||||
|
|
||||||
The script:
|
|
||||||
|
|
||||||
1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
|
|
||||||
2. Removes the `export namespace Foo {` line and the matching closing `}`.
|
|
||||||
3. Dedents the body by one indent level (2 spaces).
|
|
||||||
4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
|
|
||||||
5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
|
|
||||||
6. Never creates a barrel `index.ts`.
|
|
||||||
|
|
||||||
### Typical flow for one file
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Preview
|
|
||||||
bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
|
|
||||||
|
|
||||||
# 2. Apply
|
|
||||||
bun script/unwrap-namespace.ts src/permission/arity.ts
|
|
||||||
|
|
||||||
# 3. Verify
|
|
||||||
cd packages/opencode
|
|
||||||
bunx --bun tsgo --noEmit
|
|
||||||
bun run --conditions=browser ./src/index.ts generate
|
|
||||||
bun run test <affected test files>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Consumer imports usually don't need to change
|
|
||||||
|
|
||||||
Most consumers already import straight from the file, e.g.:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { BashArity } from "@/permission/arity"
|
|
||||||
import { Config } from "@/config/config"
|
|
||||||
```
|
|
||||||
|
|
||||||
Because the file itself now does `export * as Foo from "./foo"`, those imports
|
|
||||||
keep working with zero edits.
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// before
|
|
||||||
import { Config } from "@/config"
|
|
||||||
|
|
||||||
// after
|
|
||||||
import { Config } from "@/config/config"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic imports in tests
|
|
||||||
|
|
||||||
If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
|
|
||||||
still works because of the self-reexport. No change required.
|
|
||||||
|
|
||||||
## Verification checklist (per PR)
|
|
||||||
|
|
||||||
Run all of these locally before pushing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd packages/opencode
|
|
||||||
bunx --bun tsgo --noEmit
|
|
||||||
bun run --conditions=browser ./src/index.ts generate
|
|
||||||
bun run test <affected test files>
|
|
||||||
```
|
|
||||||
|
|
||||||
Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
|
|
||||||
consumer is still importing the namespace from an old barrel path that no
|
|
||||||
longer exports it.
|
|
||||||
|
|
||||||
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
|
|
||||||
import regressions at runtime — the typechecker does not catch these.
|
|
||||||
|
|
||||||
## Rules for new code
|
|
||||||
|
|
||||||
- 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 "."`.
|
|
||||||
|
|
||||||
### Why `dir/index.ts` + `"."` is fine for us
|
|
||||||
|
|
||||||
A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
|
|
||||||
with `export * as Foo from "."` at the bottom. Consumers write the
|
|
||||||
short form:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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.
|
|
||||||
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
|
|||||||
|
|
||||||
## Current route files
|
## Current route files
|
||||||
|
|
||||||
Current instance route files live under `src/server/instance`, not `server/routes`.
|
Current instance route files live under `src/server/routes/instance`.
|
||||||
|
|
||||||
The main migration targets are:
|
Files that are already mostly on the intended service-yielding shape:
|
||||||
|
|
||||||
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
|
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
|
||||||
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
|
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
|
||||||
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
|
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
|
||||||
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
|
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
|
||||||
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
|
- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service`
|
||||||
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
|
|
||||||
|
|
||||||
Additional route files that still participate in the migration:
|
Files still worth tracking here:
|
||||||
|
|
||||||
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
|
- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
|
||||||
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
|
- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
|
||||||
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
|
- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
|
||||||
- [ ] `server/instance/permission.ts` — Permission
|
- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
|
||||||
- [ ] `server/instance/workspace.ts` — Workspace
|
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
|
||||||
- [ ] `server/instance/tui.ts` — Bus and Session
|
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
|
||||||
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
|
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
|
||||||
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
|
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
|
|||||||
Important current facts:
|
Important current facts:
|
||||||
|
|
||||||
- there is no `packages/core` or `packages/cli` workspace yet
|
- there is no `packages/core` or `packages/cli` workspace yet
|
||||||
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
|
- there is no `packages/server` workspace yet on this branch
|
||||||
- the main host server is still Hono-based in `src/server/server.ts`
|
- the main host server is still Hono-based in `src/server/server.ts`
|
||||||
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
|
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
|
||||||
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
|
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
|
||||||
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
|
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
|
||||||
- that experimental slice is mounted under `/experimental/httpapi/question`
|
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
|
||||||
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
|
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
|
||||||
|
|
||||||
This means the package split should start from an extraction path, not from greenfield package ownership.
|
This means the package split should start from an extraction path, not from greenfield package ownership.
|
||||||
|
|
||||||
@@ -209,17 +209,19 @@ Current host and route composition:
|
|||||||
|
|
||||||
- `src/server/server.ts`
|
- `src/server/server.ts`
|
||||||
- `src/server/control/index.ts`
|
- `src/server/control/index.ts`
|
||||||
- `src/server/instance/index.ts`
|
- `src/server/routes/instance/index.ts`
|
||||||
- `src/server/middleware.ts`
|
- `src/server/middleware.ts`
|
||||||
- `src/server/adapter.bun.ts`
|
- `src/server/adapter.bun.ts`
|
||||||
- `src/server/adapter.node.ts`
|
- `src/server/adapter.node.ts`
|
||||||
|
|
||||||
Current experimental `HttpApi` slice:
|
Current bridged `HttpApi` slices:
|
||||||
|
|
||||||
- `src/server/instance/httpapi/question.ts`
|
- `src/server/routes/instance/httpapi/question.ts`
|
||||||
- `src/server/instance/httpapi/index.ts`
|
- `src/server/routes/instance/httpapi/permission.ts`
|
||||||
- `src/server/instance/experimental.ts`
|
- `src/server/routes/instance/httpapi/provider.ts`
|
||||||
- `test/server/question-httpapi.test.ts`
|
- `src/server/routes/instance/httpapi/config.ts`
|
||||||
|
- `src/server/routes/instance/httpapi/project.ts`
|
||||||
|
- `src/server/routes/instance/httpapi/server.ts`
|
||||||
|
|
||||||
Current OpenAPI flow:
|
Current OpenAPI flow:
|
||||||
|
|
||||||
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
|
|||||||
|
|
||||||
- `src/server/server.ts`
|
- `src/server/server.ts`
|
||||||
- `src/server/control/index.ts`
|
- `src/server/control/index.ts`
|
||||||
- `src/server/instance/*.ts`
|
- `src/server/routes/**/*.ts`
|
||||||
- `src/server/middleware.ts`
|
- `src/server/middleware.ts`
|
||||||
- `src/server/adapter.*.ts`
|
- `src/server/adapter.*.ts`
|
||||||
- `src/effect/app-runtime.ts`
|
- `src/effect/app-runtime.ts`
|
||||||
@@ -305,14 +307,13 @@ Bad early migration targets:
|
|||||||
|
|
||||||
## First vertical slice
|
## First vertical slice
|
||||||
|
|
||||||
The first slice for the package split is the existing experimental `question` group.
|
The first slice for the package split is still the existing `question` `HttpApi` group.
|
||||||
|
|
||||||
Why `question` first:
|
Why `question` first:
|
||||||
|
|
||||||
- it already exists as an experimental `HttpApi` slice
|
- it already exists as an experimental `HttpApi` slice
|
||||||
- it already follows the desired contract and implementation split in one file
|
- it already follows the desired contract and implementation split in one file
|
||||||
- it is already mounted through the current Hono host
|
- it is already mounted through the current Hono host
|
||||||
- it already has an end-to-end test
|
|
||||||
- it is JSON-only
|
- it is JSON-only
|
||||||
- it has low blast radius
|
- it has low blast radius
|
||||||
|
|
||||||
@@ -357,7 +358,7 @@ Done means:
|
|||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
|
|
||||||
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
|
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
|
||||||
- place it in `packages/server/src/definition/question.ts`
|
- place it in `packages/server/src/definition/question.ts`
|
||||||
- aggregate it in `packages/server/src/definition/api.ts`
|
- aggregate it in `packages/server/src/definition/api.ts`
|
||||||
- generate OpenAPI in `packages/server/src/openapi.ts`
|
- generate OpenAPI in `packages/server/src/openapi.ts`
|
||||||
@@ -399,8 +400,9 @@ Scope:
|
|||||||
|
|
||||||
- replace local experimental question route wiring in `packages/opencode`
|
- replace local experimental question route wiring in `packages/opencode`
|
||||||
- keep the same mount path:
|
- keep the same mount path:
|
||||||
- `/experimental/httpapi/question`
|
- `/question`
|
||||||
- `/experimental/httpapi/question/doc`
|
- `/question/:requestID/reply`
|
||||||
|
- `/question/:requestID/reject`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
|
|||||||
Typical validation for the first waves:
|
Typical validation for the first waves:
|
||||||
|
|
||||||
- `bun typecheck` in the touched package directory or directories
|
- `bun typecheck` in the touched package directory or directories
|
||||||
- the relevant route test, especially `test/server/question-httpapi.test.ts`
|
- the relevant server / route coverage for the migrated slice
|
||||||
- merged OpenAPI coverage if the PR touches spec generation
|
- merged OpenAPI coverage if the PR touches spec generation
|
||||||
|
|
||||||
Do not run tests from repo root.
|
Do not run tests from repo root.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
|
|||||||
|
|
||||||
## Exported tools
|
## Exported tools
|
||||||
|
|
||||||
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
|
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
|
||||||
|
|
||||||
- [x] `apply_patch.ts`
|
- [x] `apply_patch.ts`
|
||||||
- [x] `bash.ts`
|
- [x] `bash.ts`
|
||||||
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
|
|||||||
- [x] `glob.ts`
|
- [x] `glob.ts`
|
||||||
- [x] `grep.ts`
|
- [x] `grep.ts`
|
||||||
- [x] `invalid.ts`
|
- [x] `invalid.ts`
|
||||||
- [x] `ls.ts`
|
|
||||||
- [x] `lsp.ts`
|
- [x] `lsp.ts`
|
||||||
- [x] `multiedit.ts`
|
- [x] `multiedit.ts`
|
||||||
- [x] `plan.ts`
|
- [x] `plan.ts`
|
||||||
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `batch.ts` is no longer a current tool file and should not be tracked here.
|
- There is no current `ls.ts` tool file on this branch.
|
||||||
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
|
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
|
||||||
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
|
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
|
|||||||
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
|
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
|
||||||
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
||||||
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
||||||
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
|
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
|
||||||
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
|
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
|
||||||
|
|
||||||
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
||||||
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
|
|||||||
- `write.ts`
|
- `write.ts`
|
||||||
- `codesearch.ts`
|
- `codesearch.ts`
|
||||||
- `websearch.ts`
|
- `websearch.ts`
|
||||||
- `ls.ts`
|
|
||||||
- `multiedit.ts`
|
- `multiedit.ts`
|
||||||
- `edit.ts`
|
- `edit.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -181,10 +181,10 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const repo = yield* AccountRepo
|
const repo = yield* AccountRepo.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const httpRead = withTransientReadRetry(http)
|
const httpRead = withTransientReadRetry(http)
|
||||||
const httpOk = HttpClient.filterStatusOk(http)
|
const httpOk = HttpClient.filterStatusOk(http)
|
||||||
@@ -452,3 +452,5 @@ export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpCli
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
|
||||||
|
export * as Account from "./account"
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export * as Account from "./account"
|
|
||||||
export {
|
|
||||||
AccountID,
|
|
||||||
type AccountError,
|
|
||||||
AccountRepoError,
|
|
||||||
AccountServiceError,
|
|
||||||
AccountTransportError,
|
|
||||||
AccessToken,
|
|
||||||
RefreshToken,
|
|
||||||
DeviceCode,
|
|
||||||
UserCode,
|
|
||||||
Info,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
Login,
|
|
||||||
PollSuccess,
|
|
||||||
PollPending,
|
|
||||||
PollSlow,
|
|
||||||
PollExpired,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
type PollResult,
|
|
||||||
} from "./schema"
|
|
||||||
export type { AccountOrgs, ActiveOrg } from "./account"
|
|
||||||
@@ -13,8 +13,7 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
|||||||
|
|
||||||
const ACCOUNT_STATE_ID = 1
|
const ACCOUNT_STATE_ID = 1
|
||||||
|
|
||||||
export namespace AccountRepo {
|
export interface Interface {
|
||||||
export interface Service {
|
|
||||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
|
||||||
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
readonly list: () => Effect.Effect<Info[], AccountRepoError>
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||||
@@ -35,12 +34,12 @@ export namespace AccountRepo {
|
|||||||
expiry: number
|
expiry: number
|
||||||
orgID: Option.Option<OrgID>
|
orgID: Option.Option<OrgID>
|
||||||
}) => Effect.Effect<void, AccountRepoError>
|
}) => Effect.Effect<void, AccountRepoError>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
|
||||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
|
||||||
AccountRepo,
|
export const layer: Layer.Layer<Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
@@ -152,7 +151,7 @@ export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Servic
|
|||||||
}).pipe(Effect.asVoid),
|
}).pipe(Effect.asVoid),
|
||||||
)
|
)
|
||||||
|
|
||||||
return AccountRepo.of({
|
return Service.of({
|
||||||
active,
|
active,
|
||||||
list,
|
list,
|
||||||
remove,
|
remove,
|
||||||
@@ -162,5 +161,6 @@ export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Servic
|
|||||||
persistAccount,
|
persistAccount,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
export * as AccountRepo from "./repo"
|
||||||
|
|||||||
@@ -57,15 +57,14 @@ type ModelOption = { modelId: string; name: string }
|
|||||||
|
|
||||||
const DEFAULT_VARIANT_VALUE = "default"
|
const DEFAULT_VARIANT_VALUE = "default"
|
||||||
|
|
||||||
export namespace ACP {
|
const log = Log.create({ service: "acp-agent" })
|
||||||
const log = Log.create({ service: "acp-agent" })
|
|
||||||
|
|
||||||
async function getContextLimit(
|
async function getContextLimit(
|
||||||
sdk: OpencodeClient,
|
sdk: OpencodeClient,
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
modelID: ModelID,
|
modelID: ModelID,
|
||||||
directory: string,
|
directory: string,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const providers = await sdk.config
|
const providers = await sdk.config
|
||||||
.providers({ directory })
|
.providers({ directory })
|
||||||
.then((x) => x.data?.providers ?? [])
|
.then((x) => x.data?.providers ?? [])
|
||||||
@@ -77,14 +76,14 @@ export namespace ACP {
|
|||||||
const provider = providers.find((p) => p.id === providerID)
|
const provider = providers.find((p) => p.id === providerID)
|
||||||
const model = provider?.models[modelID]
|
const model = provider?.models[modelID]
|
||||||
return model?.limit.context ?? null
|
return model?.limit.context ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendUsageUpdate(
|
async function sendUsageUpdate(
|
||||||
connection: AgentSideConnection,
|
connection: AgentSideConnection,
|
||||||
sdk: OpencodeClient,
|
sdk: OpencodeClient,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
directory: string,
|
directory: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const messages = await sdk.session
|
const messages = await sdk.session
|
||||||
.messages({ sessionID, directory }, { throwOnError: true })
|
.messages({ sessionID, directory }, { throwOnError: true })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
@@ -127,17 +126,17 @@ export namespace ACP {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log.error("failed to send usage update", { error })
|
log.error("failed to send usage update", { error })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||||
return {
|
return {
|
||||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||||
return new Agent(connection, fullConfig)
|
return new Agent(connection, fullConfig)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Agent implements ACPAgent {
|
export class Agent implements ACPAgent {
|
||||||
private connection: AgentSideConnection
|
private connection: AgentSideConnection
|
||||||
private config: ACPConfig
|
private config: ACPConfig
|
||||||
private sdk: OpencodeClient
|
private sdk: OpencodeClient
|
||||||
@@ -1163,8 +1162,7 @@ export namespace ACP {
|
|||||||
(await (async () => {
|
(await (async () => {
|
||||||
if (!availableModes.length) return undefined
|
if (!availableModes.length) return undefined
|
||||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||||
const resolvedModeId =
|
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||||
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
|
||||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||||
return resolvedModeId
|
return resolvedModeId
|
||||||
})())
|
})())
|
||||||
@@ -1363,8 +1361,7 @@ export namespace ACP {
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
this.sessionManager.setModel(session.id, model)
|
this.sessionManager.setModel(session.id, model)
|
||||||
}
|
}
|
||||||
const agent =
|
const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
|
||||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
|
|
||||||
|
|
||||||
const parts: Array<
|
const parts: Array<
|
||||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||||
@@ -1547,9 +1544,9 @@ export namespace ACP {
|
|||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toToolKind(toolName: string): ToolKind {
|
function toToolKind(toolName: string): ToolKind {
|
||||||
const tool = toolName.toLocaleLowerCase()
|
const tool = toolName.toLocaleLowerCase()
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "bash":
|
case "bash":
|
||||||
@@ -1574,9 +1571,9 @@ export namespace ACP {
|
|||||||
default:
|
default:
|
||||||
return "other"
|
return "other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||||
const tool = toolName.toLocaleLowerCase()
|
const tool = toolName.toLocaleLowerCase()
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "read":
|
case "read":
|
||||||
@@ -1591,9 +1588,9 @@ export namespace ACP {
|
|||||||
default:
|
default:
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
|
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
|
||||||
const sdk = config.sdk
|
const sdk = config.sdk
|
||||||
const configured = config.defaultModel
|
const configured = config.defaultModel
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
@@ -1653,11 +1650,11 @@ export namespace ACP {
|
|||||||
if (specified) return specified
|
if (specified) return specified
|
||||||
|
|
||||||
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUri(
|
function parseUri(
|
||||||
uri: string,
|
uri: string,
|
||||||
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
||||||
try {
|
try {
|
||||||
if (uri.startsWith("file://")) {
|
if (uri.startsWith("file://")) {
|
||||||
const path = uri.slice(7)
|
const path = uri.slice(7)
|
||||||
@@ -1692,18 +1689,18 @@ export namespace ACP {
|
|||||||
text: uri,
|
text: uri,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
|
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
|
||||||
const result = applyPatch(fileOriginal, unifiedDiff)
|
const result = applyPatch(fileOriginal, unifiedDiff)
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
log.error("Failed to apply unified diff (context mismatch)")
|
log.error("Failed to apply unified diff (context mismatch)")
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
|
function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
|
||||||
return [...providers].sort((a, b) => {
|
return [...providers].sort((a, b) => {
|
||||||
const nameA = a.name.toLowerCase()
|
const nameA = a.name.toLowerCase()
|
||||||
const nameB = b.name.toLowerCase()
|
const nameB = b.name.toLowerCase()
|
||||||
@@ -1711,28 +1708,26 @@ export namespace ACP {
|
|||||||
if (nameA > nameB) return 1
|
if (nameA > nameB) return 1
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelVariantsFromProviders(
|
function modelVariantsFromProviders(
|
||||||
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
): string[] {
|
): string[] {
|
||||||
const provider = providers.find((entry) => entry.id === model.providerID)
|
const provider = providers.find((entry) => entry.id === model.providerID)
|
||||||
if (!provider) return []
|
if (!provider) return []
|
||||||
const modelInfo = provider.models[model.modelID]
|
const modelInfo = provider.models[model.modelID]
|
||||||
if (!modelInfo?.variants) return []
|
if (!modelInfo?.variants) return []
|
||||||
return Object.keys(modelInfo.variants)
|
return Object.keys(modelInfo.variants)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAvailableModels(
|
function buildAvailableModels(
|
||||||
providers: Array<{ id: string; name: string; models: Record<string, any> }>,
|
providers: Array<{ id: string; name: string; models: Record<string, any> }>,
|
||||||
options: { includeVariants?: boolean } = {},
|
options: { includeVariants?: boolean } = {},
|
||||||
): ModelOption[] {
|
): ModelOption[] {
|
||||||
const includeVariants = options.includeVariants ?? false
|
const includeVariants = options.includeVariants ?? false
|
||||||
return providers.flatMap((provider) => {
|
return providers.flatMap((provider) => {
|
||||||
const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(
|
const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(provider.models)
|
||||||
provider.models,
|
|
||||||
)
|
|
||||||
const models = Provider.sort(unsorted)
|
const models = Provider.sort(unsorted)
|
||||||
return models.flatMap((model) => {
|
return models.flatMap((model) => {
|
||||||
const base: ModelOption = {
|
const base: ModelOption = {
|
||||||
@@ -1748,24 +1743,24 @@ export namespace ACP {
|
|||||||
return [base, ...variantOptions]
|
return [base, ...variantOptions]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModelIdWithVariant(
|
function formatModelIdWithVariant(
|
||||||
model: { providerID: ProviderID; modelID: ModelID },
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
variant: string | undefined,
|
variant: string | undefined,
|
||||||
availableVariants: string[],
|
availableVariants: string[],
|
||||||
includeVariant: boolean,
|
includeVariant: boolean,
|
||||||
) {
|
) {
|
||||||
const base = `${model.providerID}/${model.modelID}`
|
const base = `${model.providerID}/${model.modelID}`
|
||||||
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
|
if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
|
||||||
return `${base}/${variant}`
|
return `${base}/${variant}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVariantMeta(input: {
|
function buildVariantMeta(input: {
|
||||||
model: { providerID: ProviderID; modelID: ModelID }
|
model: { providerID: ProviderID; modelID: ModelID }
|
||||||
variant?: string
|
variant?: string
|
||||||
availableVariants: string[]
|
availableVariants: string[]
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
opencode: {
|
opencode: {
|
||||||
modelId: `${input.model.providerID}/${input.model.modelID}`,
|
modelId: `${input.model.providerID}/${input.model.modelID}`,
|
||||||
@@ -1773,12 +1768,12 @@ export namespace ACP {
|
|||||||
availableVariants: input.availableVariants,
|
availableVariants: input.availableVariants,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModelSelection(
|
function parseModelSelection(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
||||||
): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
|
): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
|
||||||
const parsed = Provider.parseModel(modelId)
|
const parsed = Provider.parseModel(modelId)
|
||||||
const provider = providers.find((p) => p.id === parsed.providerID)
|
const provider = providers.find((p) => p.id === parsed.providerID)
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -1805,13 +1800,13 @@ export namespace ACP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { model: parsed, variant: undefined }
|
return { model: parsed, variant: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConfigOptions(input: {
|
function buildConfigOptions(input: {
|
||||||
currentModelId: string
|
currentModelId: string
|
||||||
availableModels: ModelOption[]
|
availableModels: ModelOption[]
|
||||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||||
}): SessionConfigOption[] {
|
}): SessionConfigOption[] {
|
||||||
const options: SessionConfigOption[] = [
|
const options: SessionConfigOption[] = [
|
||||||
{
|
{
|
||||||
id: "model",
|
id: "model",
|
||||||
@@ -1837,5 +1832,6 @@ export namespace ACP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return options
|
return options
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * as ACP from "./agent"
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import { InstanceState } from "@/effect"
|
|||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
|
|
||||||
export namespace Agent {
|
export const Info = z
|
||||||
export const Info = z
|
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -50,11 +49,11 @@ export namespace Agent {
|
|||||||
.meta({
|
.meta({
|
||||||
ref: "Agent",
|
ref: "Agent",
|
||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (agent: string) => Effect.Effect<Agent.Info>
|
readonly get: (agent: string) => Effect.Effect<Info>
|
||||||
readonly list: () => Effect.Effect<Agent.Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly defaultAgent: () => Effect.Effect<string>
|
readonly defaultAgent: () => Effect.Effect<string>
|
||||||
readonly generate: (input: {
|
readonly generate: (input: {
|
||||||
description: string
|
description: string
|
||||||
@@ -64,13 +63,13 @@ export namespace Agent {
|
|||||||
whenToUse: string
|
whenToUse: string
|
||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = Omit<Interface, "generate">
|
type State = Omit<Interface, "generate">
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
@@ -137,8 +136,7 @@ export namespace Agent {
|
|||||||
edit: {
|
edit: {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
|
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||||
"allow",
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -400,13 +398,14 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
export * as Agent from "./agent"
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
|
||||||
import { zod } from "@/util/effect-zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
|
||||||
|
|
||||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
|
||||||
|
|
||||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
|
||||||
type: Schema.Literal("oauth"),
|
|
||||||
refresh: Schema.String,
|
|
||||||
access: Schema.String,
|
|
||||||
expires: Schema.Number,
|
|
||||||
accountId: Schema.optional(Schema.String),
|
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
|
||||||
type: Schema.Literal("api"),
|
|
||||||
key: Schema.String,
|
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
|
||||||
type: Schema.Literal("wellknown"),
|
|
||||||
key: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
|
||||||
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
|
||||||
export type Info = Schema.Schema.Type<typeof _Info>
|
|
||||||
|
|
||||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fsys = yield* AppFileSystem.Service
|
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(function* () {
|
|
||||||
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
|
||||||
return (yield* all())[providerID]
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
if (norm !== key) delete data[key]
|
|
||||||
delete data[norm + "/"]
|
|
||||||
yield* fsys
|
|
||||||
.writeJson(file, { ...data, [norm]: info }, 0o600)
|
|
||||||
.pipe(Effect.mapError(fail("Failed to write auth data")))
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
delete data[key]
|
|
||||||
delete data[norm]
|
|
||||||
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
|
||||||
@@ -1,2 +1,97 @@
|
|||||||
export * as Auth from "./auth"
|
import path from "path"
|
||||||
export { OAUTH_DUMMY_KEY } from "./auth"
|
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
||||||
|
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||||
|
|
||||||
|
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||||
|
type: Schema.Literal("oauth"),
|
||||||
|
refresh: Schema.String,
|
||||||
|
access: Schema.String,
|
||||||
|
expires: Schema.Number,
|
||||||
|
accountId: Schema.optional(Schema.String),
|
||||||
|
enterpriseUrl: Schema.optional(Schema.String),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||||
|
type: Schema.Literal("api"),
|
||||||
|
key: Schema.String,
|
||||||
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||||
|
type: Schema.Literal("wellknown"),
|
||||||
|
key: Schema.String,
|
||||||
|
token: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
||||||
|
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
||||||
|
export type Info = Schema.Schema.Type<typeof _Info>
|
||||||
|
|
||||||
|
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
|
const all = Effect.fn("Auth.all")(function* () {
|
||||||
|
if (process.env.OPENCODE_AUTH_CONTENT) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||||
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||||
|
return (yield* all())[providerID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
if (norm !== key) delete data[key]
|
||||||
|
delete data[norm + "/"]
|
||||||
|
yield* fsys
|
||||||
|
.writeJson(file, { ...data, [norm]: info }, 0o600)
|
||||||
|
.pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
delete data[key]
|
||||||
|
delete data[norm]
|
||||||
|
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
export * as Auth from "."
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodType } from "zod"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export type Definition = ReturnType<typeof define>
|
||||||
export type Definition = ReturnType<typeof define>
|
|
||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
}
|
}
|
||||||
registry.set(type, result)
|
registry.set(type, result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function payloads() {
|
export function payloads() {
|
||||||
return registry
|
return registry
|
||||||
.entries()
|
.entries()
|
||||||
.map(([type, def]) => {
|
.map(([type, def]) => {
|
||||||
@@ -29,5 +28,6 @@ export namespace BusEvent {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.toArray()
|
.toArray()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * as BusEvent from "./bus-event"
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { BusEvent } from "./bus-event"
|
|
||||||
import { GlobalBus } from "./global"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "bus" })
|
|
||||||
|
|
||||||
export const InstanceDisposed = BusEvent.define(
|
|
||||||
"server.instance.disposed",
|
|
||||||
z.object({
|
|
||||||
directory: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
|
||||||
type: D["type"]
|
|
||||||
properties: z.infer<D["properties"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
wildcard: PubSub.PubSub<Payload>
|
|
||||||
typed: Map<string, PubSub.PubSub<Payload>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly publish: <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
properties: z.output<D["properties"]>,
|
|
||||||
) => Effect.Effect<void>
|
|
||||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
|
||||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
|
||||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: Payload<D>) => unknown,
|
|
||||||
) => Effect.Effect<() => void>
|
|
||||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Bus.state")(function* (ctx) {
|
|
||||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
|
||||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
|
||||||
yield* PubSub.publish(wildcard, {
|
|
||||||
type: InstanceDisposed.type,
|
|
||||||
properties: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
yield* PubSub.shutdown(wildcard)
|
|
||||||
for (const ps of typed.values()) {
|
|
||||||
yield* PubSub.shutdown(ps)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { wildcard, typed }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
let ps = state.typed.get(def.type)
|
|
||||||
if (!ps) {
|
|
||||||
ps = yield* PubSub.unbounded<Payload>()
|
|
||||||
state.typed.set(def.type, ps)
|
|
||||||
}
|
|
||||||
return ps as unknown as PubSub.PubSub<Payload<D>>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const payload: Payload = { type: def.type, properties }
|
|
||||||
log.info("publishing", { type: def.type })
|
|
||||||
|
|
||||||
const ps = s.typed.get(def.type)
|
|
||||||
if (ps) yield* PubSub.publish(ps, payload)
|
|
||||||
yield* PubSub.publish(s.wildcard, payload)
|
|
||||||
|
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
const context = yield* InstanceState.context
|
|
||||||
const workspace = yield* InstanceState.workspaceID
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: dir,
|
|
||||||
project: context.project.id,
|
|
||||||
workspace,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
|
||||||
log.info("subscribing", { type: def.type })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const ps = yield* getOrCreate(s, def)
|
|
||||||
return Stream.fromPubSub(ps)
|
|
||||||
}),
|
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeAll(): Stream.Stream<Payload> {
|
|
||||||
log.info("subscribing", { type: "*" })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Stream.fromPubSub(s.wildcard)
|
|
||||||
}),
|
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
log.info("subscribing", { type })
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
const scope = yield* Scope.make()
|
|
||||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
|
||||||
|
|
||||||
yield* Scope.provide(scope)(
|
|
||||||
Stream.fromSubscription(subscription).pipe(
|
|
||||||
Stream.runForEach((msg) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => Promise.resolve().then(() => callback(msg)),
|
|
||||||
catch: (cause) => {
|
|
||||||
log.error("subscriber failed", { type, cause })
|
|
||||||
},
|
|
||||||
}).pipe(Effect.ignore),
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
log.info("unsubscribing", { type })
|
|
||||||
bridge.fork(Scope.close(scope, Exit.void))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: Payload<D>) => unknown,
|
|
||||||
) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const ps = yield* getOrCreate(s, def)
|
|
||||||
return yield* on(ps, def.type, callback)
|
|
||||||
})
|
|
||||||
|
|
||||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return yield* on(s.wildcard, "*", callback)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
|
|
||||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
|
||||||
|
|
||||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
|
||||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
|
||||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
|
||||||
return runPromise((svc) => svc.publish(def, properties))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe<D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
|
||||||
) {
|
|
||||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeAll(callback: (event: any) => unknown) {
|
|
||||||
return runSync((svc) => svc.subscribeAllCallback(callback))
|
|
||||||
}
|
|
||||||
@@ -1 +1,193 @@
|
|||||||
export * as Bus from "./bus"
|
import z from "zod"
|
||||||
|
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { BusEvent } from "./bus-event"
|
||||||
|
import { GlobalBus } from "./global"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "bus" })
|
||||||
|
|
||||||
|
export const InstanceDisposed = BusEvent.define(
|
||||||
|
"server.instance.disposed",
|
||||||
|
z.object({
|
||||||
|
directory: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||||
|
type: D["type"]
|
||||||
|
properties: z.infer<D["properties"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
wildcard: PubSub.PubSub<Payload>
|
||||||
|
typed: Map<string, PubSub.PubSub<Payload>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly publish: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
properties: z.output<D["properties"]>,
|
||||||
|
) => Effect.Effect<void>
|
||||||
|
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||||
|
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||||
|
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) => Effect.Effect<() => void>
|
||||||
|
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Bus.state")(function* (ctx) {
|
||||||
|
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||||
|
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||||
|
yield* PubSub.publish(wildcard, {
|
||||||
|
type: InstanceDisposed.type,
|
||||||
|
properties: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
yield* PubSub.shutdown(wildcard)
|
||||||
|
for (const ps of typed.values()) {
|
||||||
|
yield* PubSub.shutdown(ps)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { wildcard, typed }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
let ps = state.typed.get(def.type)
|
||||||
|
if (!ps) {
|
||||||
|
ps = yield* PubSub.unbounded<Payload>()
|
||||||
|
state.typed.set(def.type, ps)
|
||||||
|
}
|
||||||
|
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const payload: Payload = { type: def.type, properties }
|
||||||
|
log.info("publishing", { type: def.type })
|
||||||
|
|
||||||
|
const ps = s.typed.get(def.type)
|
||||||
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
|
yield* PubSub.publish(s.wildcard, payload)
|
||||||
|
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
|
const context = yield* InstanceState.context
|
||||||
|
const workspace = yield* InstanceState.workspaceID
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: dir,
|
||||||
|
project: context.project.id,
|
||||||
|
workspace,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||||
|
log.info("subscribing", { type: def.type })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const ps = yield* getOrCreate(s, def)
|
||||||
|
return Stream.fromPubSub(ps)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeAll(): Stream.Stream<Payload> {
|
||||||
|
log.info("subscribing", { type: "*" })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Stream.fromPubSub(s.wildcard)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
log.info("subscribing", { type })
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||||
|
|
||||||
|
yield* Scope.provide(scope)(
|
||||||
|
Stream.fromSubscription(subscription).pipe(
|
||||||
|
Stream.runForEach((msg) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => Promise.resolve().then(() => callback(msg)),
|
||||||
|
catch: (cause) => {
|
||||||
|
log.error("subscriber failed", { type, cause })
|
||||||
|
},
|
||||||
|
}).pipe(Effect.ignore),
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
log.info("unsubscribing", { type })
|
||||||
|
bridge.fork(Scope.close(scope, Exit.void))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const ps = yield* getOrCreate(s, def)
|
||||||
|
return yield* on(ps, def.type, callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return yield* on(s.wildcard, "*", callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
|
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||||
|
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||||
|
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return runPromise((svc) => svc.publish(def, properties))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe<D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||||
|
) {
|
||||||
|
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAll(callback: (event: any) => unknown) {
|
||||||
|
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Bus from "."
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { Account } from "@/account/account"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|||||||
@@ -148,7 +148,16 @@ export function tui(input: {
|
|||||||
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
|
||||||
<KVProvider>
|
<KVProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouteProvider>
|
<RouteProvider
|
||||||
|
initialRoute={
|
||||||
|
input.args.continue
|
||||||
|
? {
|
||||||
|
type: "session",
|
||||||
|
sessionID: "dummy",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<TuiConfigProvider config={input.config}>
|
<TuiConfigProvider config={input.config}>
|
||||||
<SDKProvider
|
<SDKProvider
|
||||||
url={input.url}
|
url={input.url}
|
||||||
@@ -333,7 +342,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
})
|
})
|
||||||
local.model.set({ providerID, modelID }, { recent: true })
|
local.model.set({ providerID, modelID }, { recent: true })
|
||||||
}
|
}
|
||||||
// Handle --session without --fork immediately (fork is handled in createEffect below)
|
|
||||||
if (args.sessionID && !args.fork) {
|
if (args.sessionID && !args.fork) {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
@@ -420,12 +428,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
aliases: ["clear"],
|
aliases: ["clear"],
|
||||||
},
|
},
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const current = promptRef.current
|
|
||||||
// Don't require focus - if there's any text, preserve it
|
|
||||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
initialPrompt: currentPrompt,
|
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -602,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Toggle theme mode",
|
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||||
value: "theme.switch_mode",
|
value: "theme.switch_mode",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
setMode(mode() === "dark" ? "light" : "dark")
|
setMode(mode() === "dark" ? "light" : "dark")
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { BoxRenderable, RGBA } from "@opentui/core"
|
||||||
|
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
|
||||||
|
import { tint, useTheme } from "@tui/context/theme"
|
||||||
|
|
||||||
|
const PERIOD = 4600
|
||||||
|
const RINGS = 3
|
||||||
|
const WIDTH = 3.8
|
||||||
|
const TAIL = 9.5
|
||||||
|
const AMP = 0.55
|
||||||
|
const TAIL_AMP = 0.16
|
||||||
|
const BREATH_AMP = 0.05
|
||||||
|
const BREATH_SPEED = 0.0008
|
||||||
|
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
|
||||||
|
const PHASE_OFFSET = 0.29
|
||||||
|
|
||||||
|
export type BgPulseMask = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
pad?: number
|
||||||
|
strength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const [now, setNow] = createSignal(performance.now())
|
||||||
|
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||||
|
let box: BoxRenderable | undefined
|
||||||
|
|
||||||
|
const timer = setInterval(() => setNow(performance.now()), 50)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!box) return
|
||||||
|
setSize({ width: box.width, height: box.height })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sync()
|
||||||
|
box?.on("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
box?.off("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
const grid = createMemo(() => {
|
||||||
|
const t = now()
|
||||||
|
const w = size().width
|
||||||
|
const h = size().height
|
||||||
|
if (w === 0 || h === 0) return [] as RGBA[][]
|
||||||
|
const cxv = props.centerX ?? w / 2
|
||||||
|
const cyv = props.centerY ?? h / 2
|
||||||
|
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
|
||||||
|
const ringStates = Array.from({ length: RINGS }, (_, i) => {
|
||||||
|
const offset = i / RINGS
|
||||||
|
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
|
||||||
|
const envelope = Math.sin(phase * Math.PI)
|
||||||
|
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||||
|
return {
|
||||||
|
head: phase * reach,
|
||||||
|
eased,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const normalizedMasks = props.masks?.map((m) => {
|
||||||
|
const pad = m.pad ?? 2
|
||||||
|
return {
|
||||||
|
left: m.x - pad,
|
||||||
|
right: m.x + m.width + pad,
|
||||||
|
top: m.y - pad,
|
||||||
|
bottom: m.y + m.height + pad,
|
||||||
|
pad,
|
||||||
|
strength: m.strength ?? 0.85,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rows = [] as RGBA[][]
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const row = [] as RGBA[]
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const dx = x + 0.5 - cxv
|
||||||
|
const dy = (y + 0.5 - cyv) * 2
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
let level = 0
|
||||||
|
for (const ring of ringStates) {
|
||||||
|
const delta = dist - ring.head
|
||||||
|
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
|
||||||
|
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
|
||||||
|
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
|
||||||
|
}
|
||||||
|
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
|
||||||
|
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||||
|
let maskAtten = 1
|
||||||
|
if (normalizedMasks) {
|
||||||
|
for (const m of normalizedMasks) {
|
||||||
|
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
|
||||||
|
const inX = Math.min(x - m.left, m.right - x)
|
||||||
|
const inY = Math.min(y - m.top, m.bottom - y)
|
||||||
|
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
|
||||||
|
const eased = edge * edge * (3 - 2 * edge)
|
||||||
|
const reduce = 1 - m.strength * eased
|
||||||
|
if (reduce < maskAtten) maskAtten = reduce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
|
||||||
|
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
|
||||||
|
}
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
|
||||||
|
<For each={grid()}>
|
||||||
|
{(row) => (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<For each={row}>
|
||||||
|
{(color) => (
|
||||||
|
<text bg={color} fg={color} selectable={false}>
|
||||||
|
{" "}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ function init() {
|
|||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (suspended()) return
|
if (suspended()) return
|
||||||
if (dialog.stack.length > 0) return
|
if (dialog.stack.length > 0) return
|
||||||
|
if (evt.defaultPrevented) return
|
||||||
for (const option of entries()) {
|
for (const option of entries()) {
|
||||||
if (!isEnabled(option)) continue
|
if (!isEnabled(option)) continue
|
||||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { RGBA, TextAttributes } from "@opentui/core"
|
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||||
import { Link } from "@tui/ui/link"
|
import { Link } from "@tui/ui/link"
|
||||||
|
import { GoLogo } from "./logo"
|
||||||
|
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
||||||
|
|
||||||
const GO_URL = "https://opencode.ai/go"
|
const GO_URL = "https://opencode.ai/go"
|
||||||
|
const PAD_X = 3
|
||||||
|
const PAD_TOP_OUTER = 1
|
||||||
|
|
||||||
export type DialogGoUpsellProps = {
|
export type DialogGoUpsellProps = {
|
||||||
onClose?: (dontShowAgain?: boolean) => void
|
onClose?: (dontShowAgain?: boolean) => void
|
||||||
@@ -27,21 +31,64 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const fg = selectedForeground(theme)
|
const fg = selectedForeground(theme)
|
||||||
const [selected, setSelected] = createSignal(0)
|
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
|
||||||
|
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
|
||||||
|
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
|
||||||
|
let content: BoxRenderable | undefined
|
||||||
|
let logoBox: BoxRenderable | undefined
|
||||||
|
let headingBox: BoxRenderable | undefined
|
||||||
|
let descBox: BoxRenderable | undefined
|
||||||
|
let buttonsBox: BoxRenderable | undefined
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!content || !logoBox) return
|
||||||
|
setCenter({
|
||||||
|
x: logoBox.x - content.x + logoBox.width / 2,
|
||||||
|
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
|
||||||
|
})
|
||||||
|
const next: BgPulseMask[] = []
|
||||||
|
const baseY = PAD_TOP_OUTER
|
||||||
|
for (const b of [headingBox, descBox, buttonsBox]) {
|
||||||
|
if (!b) continue
|
||||||
|
next.push({
|
||||||
|
x: b.x - content.x,
|
||||||
|
y: b.y - content.y + baseY,
|
||||||
|
width: b.width,
|
||||||
|
height: b.height,
|
||||||
|
pad: 2,
|
||||||
|
strength: 0.78,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setMasks(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sync()
|
||||||
|
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
|
||||||
|
})
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||||
setSelected((s) => (s === 0 ? 1 : 0))
|
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (evt.name !== "return") return
|
if (evt.name === "return") {
|
||||||
if (selected() === 0) subscribe(props, dialog)
|
if (selected() === "subscribe") subscribe(props, dialog)
|
||||||
else dismiss(props, dialog)
|
else dismiss(props, dialog)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||||
|
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||||
|
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
Free limit reached
|
Free limit reached
|
||||||
</text>
|
</text>
|
||||||
@@ -49,41 +96,52 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
|||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box gap={1} paddingBottom={1}>
|
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
|
||||||
<text fg={theme.textMuted}>
|
<box flexDirection="row">
|
||||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
<text fg={theme.textMuted}>Subscribe to </text>
|
||||||
$5/month.
|
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
|
||||||
|
OpenCode Go
|
||||||
</text>
|
</text>
|
||||||
<box flexDirection="row" gap={1}>
|
<text fg={theme.textMuted}> for reliable access to the</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
|
||||||
|
</box>
|
||||||
|
<box alignItems="center" gap={1} paddingBottom={1}>
|
||||||
|
<box ref={(item: BoxRenderable) => (logoBox = item)}>
|
||||||
|
<GoLogo />
|
||||||
|
</box>
|
||||||
<Link href={GO_URL} fg={theme.primary} />
|
<Link href={GO_URL} fg={theme.primary} />
|
||||||
</box>
|
</box>
|
||||||
</box>
|
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
|
||||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
|
||||||
<box
|
<box
|
||||||
paddingLeft={3}
|
paddingLeft={2}
|
||||||
paddingRight={3}
|
paddingRight={2}
|
||||||
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
onMouseOver={() => setSelected(0)}
|
onMouseOver={() => setSelected("dismiss")}
|
||||||
onMouseUp={() => subscribe(props, dialog)}
|
|
||||||
>
|
|
||||||
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
|
|
||||||
subscribe
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box
|
|
||||||
paddingLeft={3}
|
|
||||||
paddingRight={3}
|
|
||||||
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
|
||||||
onMouseOver={() => setSelected(1)}
|
|
||||||
onMouseUp={() => dismiss(props, dialog)}
|
onMouseUp={() => dismiss(props, dialog)}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
fg={selected() === 1 ? fg : theme.textMuted}
|
fg={selected() === "dismiss" ? fg : theme.textMuted}
|
||||||
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
|
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||||
>
|
>
|
||||||
don't show again
|
don't show again
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
<box
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||||
|
onMouseOver={() => setSelected("subscribe")}
|
||||||
|
onMouseUp={() => subscribe(props, dialog)}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={selected() === "subscribe" ? fg : theme.text}
|
||||||
|
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
|
||||||
|
>
|
||||||
|
subscribe
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -113,7 +113,11 @@ export function DialogSessionList() {
|
|||||||
const today = new Date().toDateString()
|
const today = new Date().toDateString()
|
||||||
return sessions()
|
return sessions()
|
||||||
.filter((x) => x.parentID === undefined)
|
.filter((x) => x.parentID === undefined)
|
||||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
.toSorted((a, b) => {
|
||||||
|
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
|
||||||
|
if (updatedDay !== 0) return updatedDay
|
||||||
|
return b.time.created - a.time.created
|
||||||
|
})
|
||||||
.map((x) => {
|
.map((x) => {
|
||||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||||
|
|
||||||
@@ -135,15 +139,10 @@ export function DialogSessionList() {
|
|||||||
{desc}{" "}
|
{desc}{" "}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fg:
|
fg: workspaceStatus === "connected" ? theme.success : theme.error,
|
||||||
workspaceStatus === "error"
|
|
||||||
? theme.error
|
|
||||||
: workspaceStatus === "disconnected"
|
|
||||||
? theme.textMuted
|
|
||||||
: theme.success,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
■
|
●
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ export async function openWorkspaceSession(input: {
|
|||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("opening!")
|
|
||||||
while (true) {
|
while (true) {
|
||||||
console.log("creating")
|
|
||||||
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
||||||
log.error("workspace session create request failed", {
|
log.error("workspace session create request failed", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
@@ -141,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
|
|||||||
total: result.data.total,
|
total: result.data.total,
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
|
input.project.workspace.set(input.workspaceID)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await input.sync.bootstrap({ fatal: false })
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
|
||||||
log.error("session restore refresh failed", {
|
log.error("session restore refresh failed", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -231,6 +235,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||||
|
toast.show({
|
||||||
|
message: "Creating workspace failed",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
log.error("workspace create request failed", {
|
log.error("workspace create request failed", {
|
||||||
type,
|
type,
|
||||||
error: errorData(err),
|
error: errorData(err),
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { useKeyboard } from "@opentui/solid"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
|
import { useDialog } from "../ui/dialog"
|
||||||
|
|
||||||
|
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
active: "restore" as "cancel" | "restore",
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ["cancel", "restore"] as const
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (store.active === "cancel") {
|
||||||
|
dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await props.onRestore?.()
|
||||||
|
if (result === false) return
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (evt.name === "return") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
void confirm()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (evt.name === "left") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
setStore("active", "cancel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (evt.name === "right") {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
setStore("active", "restore")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
|
Workspace Unavailable
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
This session is attached to a workspace that is no longer available.
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} wrapMode="word">
|
||||||
|
Would you like to restore this session into a new workspace?
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||||
|
<For each={options}>
|
||||||
|
{(item) => (
|
||||||
|
<box
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setStore("active", item)
|
||||||
|
void confirm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,61 @@
|
|||||||
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, onMount, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
import { useTheme, tint } from "@tui/context/theme"
|
||||||
import * as Sound from "@tui/util/sound"
|
import * as Sound from "@tui/util/sound"
|
||||||
import { logo } from "@/cli/logo"
|
import { go, logo } from "@/cli/logo"
|
||||||
|
|
||||||
|
export type LogoShape = {
|
||||||
|
left: string[]
|
||||||
|
right: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShimmerConfig = {
|
||||||
|
period: number
|
||||||
|
rings: number
|
||||||
|
sweepFraction: number
|
||||||
|
coreWidth: number
|
||||||
|
coreAmp: number
|
||||||
|
softWidth: number
|
||||||
|
softAmp: number
|
||||||
|
tail: number
|
||||||
|
tailAmp: number
|
||||||
|
haloWidth: number
|
||||||
|
haloOffset: number
|
||||||
|
haloAmp: number
|
||||||
|
breathBase: number
|
||||||
|
noise: number
|
||||||
|
ambientAmp: number
|
||||||
|
ambientCenter: number
|
||||||
|
ambientWidth: number
|
||||||
|
shadowMix: number
|
||||||
|
primaryMix: number
|
||||||
|
originX: number
|
||||||
|
originY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const shimmerConfig: ShimmerConfig = {
|
||||||
|
period: 4600,
|
||||||
|
rings: 2,
|
||||||
|
sweepFraction: 1,
|
||||||
|
coreWidth: 1.2,
|
||||||
|
coreAmp: 1.9,
|
||||||
|
softWidth: 10,
|
||||||
|
softAmp: 1.6,
|
||||||
|
tail: 5,
|
||||||
|
tailAmp: 0.64,
|
||||||
|
haloWidth: 4.3,
|
||||||
|
haloOffset: 0.6,
|
||||||
|
haloAmp: 0.16,
|
||||||
|
breathBase: 0.04,
|
||||||
|
noise: 0.1,
|
||||||
|
ambientAmp: 0.36,
|
||||||
|
ambientCenter: 0.5,
|
||||||
|
ambientWidth: 0.34,
|
||||||
|
shadowMix: 0.1,
|
||||||
|
primaryMix: 0.3,
|
||||||
|
originX: 4.5,
|
||||||
|
originY: 13.5,
|
||||||
|
}
|
||||||
|
|
||||||
// Shadow markers (rendered chars in parens):
|
// Shadow markers (rendered chars in parens):
|
||||||
// _ = full shadow cell (space with bg=shadow)
|
// _ = full shadow cell (space with bg=shadow)
|
||||||
@@ -74,9 +127,6 @@ type Frame = {
|
|||||||
spark: number
|
spark: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEFT = logo.left[0]?.length ?? 0
|
|
||||||
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
|
|
||||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
|
||||||
const NEAR = [
|
const NEAR = [
|
||||||
[1, 0],
|
[1, 0],
|
||||||
[1, 1],
|
[1, 1],
|
||||||
@@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lit(char: string) {
|
function lit(char: string) {
|
||||||
return char !== " " && char !== "_" && char !== "~"
|
return char !== " " && char !== "_" && char !== "~" && char !== ","
|
||||||
}
|
}
|
||||||
|
|
||||||
function key(x: number, y: number) {
|
function key(x: number, y: number) {
|
||||||
@@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapGlyphs() {
|
function mapGlyphs(full: string[]) {
|
||||||
const cells = [] as Array<{ x: number; y: number }>
|
const cells = [] as Array<{ x: number; y: number }>
|
||||||
|
|
||||||
for (let y = 0; y < FULL.length; y++) {
|
for (let y = 0; y < full.length; y++) {
|
||||||
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
|
||||||
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +287,25 @@ function mapGlyphs() {
|
|||||||
return { glyph, trace, center }
|
return { glyph, trace, center }
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP = mapGlyphs()
|
type LogoContext = {
|
||||||
|
LEFT: number
|
||||||
|
FULL: string[]
|
||||||
|
SPAN: number
|
||||||
|
MAP: ReturnType<typeof mapGlyphs>
|
||||||
|
shape: LogoShape
|
||||||
|
}
|
||||||
|
|
||||||
function shimmer(x: number, y: number, frame: Frame) {
|
function build(shape: LogoShape): LogoContext {
|
||||||
|
const LEFT = shape.left[0]?.length ?? 0
|
||||||
|
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
|
||||||
|
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||||
|
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT = build(logo)
|
||||||
|
const GO = build(go)
|
||||||
|
|
||||||
|
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
return frame.list.reduce((best, item) => {
|
return frame.list.reduce((best, item) => {
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < SHIMMER_IN || age > LIFE) return best
|
if (age < SHIMMER_IN || age > LIFE) return best
|
||||||
@@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||||||
const dy = y * 2 + 1 - item.y
|
const dy = y * 2 + 1 - item.y
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
const p = age / LIFE
|
const p = age / LIFE
|
||||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
const lag = r - dist
|
const lag = r - dist
|
||||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||||
@@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function remain(x: number, y: number, item: Release, t: number) {
|
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
|
||||||
const age = t - item.at
|
const age = t - item.at
|
||||||
if (age < 0 || age > LIFE) return 0
|
if (age < 0 || age > LIFE) return 0
|
||||||
const p = age / LIFE
|
const p = age / LIFE
|
||||||
const dx = x + 0.5 - item.x - 0.5
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
if (dist > r) return 1
|
if (dist > r) return 1
|
||||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function wave(x: number, y: number, frame: Frame, live: boolean) {
|
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
|
||||||
return frame.list.reduce((sum, item) => {
|
return frame.list.reduce((sum, item) => {
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < 0 || age > LIFE) return sum
|
if (age < 0 || age > LIFE) return sum
|
||||||
@@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||||||
const dx = x + 0.5 - item.x
|
const dx = x + 0.5 - item.x
|
||||||
const dy = y * 2 + 1 - item.y
|
const dy = y * 2 + 1 - item.y
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
|
||||||
const fade = (1 - p) ** 1.32
|
const fade = (1 - p) ** 1.32
|
||||||
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||||
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||||
@@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function field(x: number, y: number, frame: Frame) {
|
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const held = frame.hold
|
const held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
@@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
|
|||||||
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||||
Math.exp(-(dist * dist) / 0.15) *
|
Math.exp(-(dist * dist) / 0.15) *
|
||||||
lerp(0.08, 0.42, body)
|
lerp(0.08, 0.42, body)
|
||||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||||
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||||
}
|
}
|
||||||
|
|
||||||
function pick(x: number, y: number, frame: Frame) {
|
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const held = frame.hold
|
const held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
@@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
|
|||||||
const dx = x + 0.5 - item.x - 0.5
|
const dx = x + 0.5 - item.x - 0.5
|
||||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||||
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(x: number, y: number) {
|
function select(x: number, y: number, ctx: LogoContext) {
|
||||||
const direct = MAP.glyph.get(key(x, y))
|
const direct = ctx.MAP.glyph.get(key(x, y))
|
||||||
if (direct !== undefined) return direct
|
if (direct !== undefined) return direct
|
||||||
|
|
||||||
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
|
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||||
(item): item is number => item !== undefined,
|
(item): item is number => item !== undefined,
|
||||||
)
|
)
|
||||||
return near
|
return near
|
||||||
}
|
}
|
||||||
|
|
||||||
function trace(x: number, y: number, frame: Frame) {
|
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const held = frame.hold
|
const held = frame.hold
|
||||||
const rest = frame.release
|
const rest = frame.release
|
||||||
const item = held ?? rest
|
const item = held ?? rest
|
||||||
if (!item || item.glyph === undefined) return 0
|
if (!item || item.glyph === undefined) return 0
|
||||||
const step = MAP.trace.get(key(x, y))
|
const step = ctx.MAP.trace.get(key(x, y))
|
||||||
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||||
@@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
|
|||||||
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||||
const tail = (head - TAIL + step.l) % step.l
|
const tail = (head - TAIL + step.l) % step.l
|
||||||
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
|
||||||
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||||
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||||
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||||
return (core + glow + trail) * appear * fade
|
return (core + glow + trail) * appear * fade
|
||||||
}
|
}
|
||||||
|
|
||||||
function bloom(x: number, y: number, frame: Frame) {
|
function idle(
|
||||||
|
x: number,
|
||||||
|
pixelY: number,
|
||||||
|
frame: Frame,
|
||||||
|
ctx: LogoContext,
|
||||||
|
state: IdleState,
|
||||||
|
): { glow: number; peak: number; primary: number } {
|
||||||
|
const cfg = state.cfg
|
||||||
|
const dx = x + 0.5 - cfg.originX
|
||||||
|
const dy = pixelY - cfg.originY
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
|
||||||
|
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
|
||||||
|
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
|
||||||
|
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
|
||||||
|
const traveled = dist + jitter
|
||||||
|
let glow = 0
|
||||||
|
let peak = 0
|
||||||
|
let halo = 0
|
||||||
|
let primary = 0
|
||||||
|
let ambient = 0
|
||||||
|
for (const active of state.active) {
|
||||||
|
const head = active.head
|
||||||
|
const eased = active.eased
|
||||||
|
const delta = traveled - head
|
||||||
|
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
|
||||||
|
// so adjacent pixels have smaller brightness deltas
|
||||||
|
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
|
||||||
|
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
|
||||||
|
const tailRange = cfg.tail * 2.6
|
||||||
|
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
|
||||||
|
const haloDelta = delta + cfg.haloOffset
|
||||||
|
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
|
||||||
|
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
|
||||||
|
peak += core * cfg.coreAmp * eased
|
||||||
|
halo += haloBand * cfg.haloAmp * eased
|
||||||
|
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
|
||||||
|
primary += (haloBand + tail * 0.6) * eased
|
||||||
|
ambient += active.ambient
|
||||||
|
}
|
||||||
|
ambient /= state.rings
|
||||||
|
return {
|
||||||
|
glow: glow / state.rings,
|
||||||
|
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
|
||||||
|
primary: (primary / state.rings) * cfg.primaryMix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
|
||||||
const item = frame.glow
|
const item = frame.glow
|
||||||
if (!item) return 0
|
if (!item) return 0
|
||||||
const glyph = MAP.glyph.get(key(x, y))
|
const glyph = ctx.MAP.glyph.get(key(x, y))
|
||||||
if (glyph !== item.glyph) return 0
|
if (glyph !== item.glyph) return 0
|
||||||
const age = frame.t - item.at
|
const age = frame.t - item.at
|
||||||
if (age < 0 || age > GLOW_OUT) return 0
|
if (age < 0 || age > GLOW_OUT) return 0
|
||||||
const p = age / GLOW_OUT
|
const p = age / GLOW_OUT
|
||||||
const flash = (1 - p) ** 2
|
const flash = (1 - p) ** 2
|
||||||
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
|
||||||
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
|
||||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||||
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logo() {
|
type IdleState = {
|
||||||
|
cfg: ShimmerConfig
|
||||||
|
reach: number
|
||||||
|
rings: number
|
||||||
|
active: Array<{
|
||||||
|
head: number
|
||||||
|
eased: number
|
||||||
|
ambient: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdleState(t: number, ctx: LogoContext): IdleState {
|
||||||
|
const cfg = shimmerConfig
|
||||||
|
const w = ctx.FULL[0]?.length ?? 1
|
||||||
|
const h = ctx.FULL.length * 2
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[0, h],
|
||||||
|
[w, h],
|
||||||
|
]
|
||||||
|
let maxCorner = 0
|
||||||
|
for (const [cx, cy] of corners) {
|
||||||
|
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
|
||||||
|
if (d > maxCorner) maxCorner = d
|
||||||
|
}
|
||||||
|
const reach = maxCorner + cfg.tail * 2
|
||||||
|
const rings = Math.max(1, Math.floor(cfg.rings))
|
||||||
|
const active = [] as IdleState["active"]
|
||||||
|
for (let i = 0; i < rings; i++) {
|
||||||
|
const offset = i / rings
|
||||||
|
const cyclePhase = (t / cfg.period + offset) % 1
|
||||||
|
if (cyclePhase >= cfg.sweepFraction) continue
|
||||||
|
const phase = cyclePhase / cfg.sweepFraction
|
||||||
|
const envelope = Math.sin(phase * Math.PI)
|
||||||
|
const eased = envelope * envelope * (3 - 2 * envelope)
|
||||||
|
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
|
||||||
|
active.push({
|
||||||
|
head: phase * reach,
|
||||||
|
eased,
|
||||||
|
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { cfg, reach, rings, active }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
|
||||||
|
const ctx = props.shape ? build(props.shape) : DEFAULT
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [rings, setRings] = createSignal<Ring[]>([])
|
const [rings, setRings] = createSignal<Ring[]>([])
|
||||||
const [hold, setHold] = createSignal<Hold>()
|
const [hold, setHold] = createSignal<Hold>()
|
||||||
@@ -430,6 +592,7 @@ export function Logo() {
|
|||||||
}
|
}
|
||||||
if (!live) setRelease(undefined)
|
if (!live) setRelease(undefined)
|
||||||
if (live || hold() || release() || glow()) return
|
if (live || hold() || release() || glow()) return
|
||||||
|
if (props.idle) return
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,8 +601,20 @@ export function Logo() {
|
|||||||
timer = setInterval(tick, 16)
|
timer = setInterval(tick, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
hum = false
|
||||||
|
Sound.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!props.idle) return
|
||||||
|
setNow(performance.now())
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
|
||||||
const hit = (x: number, y: number) => {
|
const hit = (x: number, y: number) => {
|
||||||
const char = FULL[y]?.[x]
|
const char = ctx.FULL[y]?.[x]
|
||||||
return char !== undefined && char !== " "
|
return char !== undefined && char !== " "
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +623,7 @@ export function Logo() {
|
|||||||
if (last) burst(last.x, last.y)
|
if (last) burst(last.x, last.y)
|
||||||
setNow(t)
|
setNow(t)
|
||||||
if (!last) setRelease(undefined)
|
if (!last) setRelease(undefined)
|
||||||
setHold({ x, y, at: t, glyph: select(x, y) })
|
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
|
||||||
hum = false
|
hum = false
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
@@ -508,6 +683,8 @@ export function Logo() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
line: string,
|
line: string,
|
||||||
y: number,
|
y: number,
|
||||||
@@ -516,24 +693,64 @@ export function Logo() {
|
|||||||
off: number,
|
off: number,
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
dusk: Frame,
|
dusk: Frame,
|
||||||
|
state: IdleState | undefined,
|
||||||
): JSX.Element[] => {
|
): JSX.Element[] => {
|
||||||
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 Array.from(line).map((char, i) => {
|
return Array.from(line).map((char, i) => {
|
||||||
const h = field(off + i, y, frame)
|
if (char === " ") {
|
||||||
const n = wave(off + i, y, frame, lit(char)) + h
|
return (
|
||||||
const s = wave(off + i, y, dusk, false) + h
|
<text fg={ink} attributes={attrs} selectable={false}>
|
||||||
const p = lit(char) ? pick(off + i, y, frame) : 0
|
{char}
|
||||||
const e = lit(char) ? trace(off + i, y, frame) : 0
|
</text>
|
||||||
const b = lit(char) ? bloom(off + i, y, frame) : 0
|
)
|
||||||
const q = shimmer(off + i, y, frame)
|
}
|
||||||
|
|
||||||
|
const h = field(off + i, y, frame, ctx)
|
||||||
|
const charLit = lit(char)
|
||||||
|
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
|
||||||
|
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||||
|
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
|
||||||
|
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
|
||||||
|
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
|
||||||
|
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
|
||||||
|
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
|
||||||
|
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
|
||||||
|
// while the bright core stays pure white
|
||||||
|
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
|
||||||
|
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
|
||||||
|
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
|
||||||
|
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
|
||||||
|
// For the non-peak-aware brightness channels, use the average of top/bot
|
||||||
|
const pulse = {
|
||||||
|
glow: (pulseTop.glow + pulseBot.glow) / 2,
|
||||||
|
peak: (pulseTop.peak + pulseBot.peak) / 2,
|
||||||
|
primary: (pulseTop.primary + pulseBot.primary) / 2,
|
||||||
|
}
|
||||||
|
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
|
||||||
|
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
|
||||||
|
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
|
||||||
|
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
|
||||||
|
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
|
||||||
|
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
|
||||||
|
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
|
||||||
|
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
|
||||||
|
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
|
||||||
|
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
|
||||||
|
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
|
||||||
|
const n = wave(off + i, y, frame, charLit, ctx) + h
|
||||||
|
const s = wave(off + i, y, dusk, false, ctx) + h
|
||||||
|
const p = charLit ? pick(off + i, y, frame, ctx) : 0
|
||||||
|
const e = charLit ? trace(off + i, y, frame, ctx) : 0
|
||||||
|
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
|
||||||
|
const q = shimmer(off + i, y, frame, ctx)
|
||||||
|
|
||||||
if (char === "_") {
|
if (char === "_") {
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
fg={shade(ink, theme, s * 0.08)}
|
fg={shade(inkTinted, theme, s * 0.08)}
|
||||||
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||||
attributes={attrs}
|
attributes={attrs}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
>
|
>
|
||||||
@@ -545,8 +762,8 @@ export function Logo() {
|
|||||||
if (char === "^") {
|
if (char === "^") {
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
fg={shade(ink, theme, n + p + e + b)}
|
fg={shade(inkTop, theme, n + p + e + b)}
|
||||||
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||||
attributes={attrs}
|
attributes={attrs}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
>
|
>
|
||||||
@@ -557,34 +774,60 @@ export function Logo() {
|
|||||||
|
|
||||||
if (char === "~") {
|
if (char === "~") {
|
||||||
return (
|
return (
|
||||||
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
▀
|
▀
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (char === " ") {
|
if (char === ",") {
|
||||||
return (
|
return (
|
||||||
<text fg={ink} attributes={attrs} selectable={false}>
|
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
{char}
|
▄
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
|
||||||
|
if (char === "█") {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
fg={shade(inkTop, theme, n + p + e + b)}
|
||||||
|
bg={shade(inkBot, theme, n + p + e + b)}
|
||||||
|
attributes={attrs}
|
||||||
|
selectable={false}
|
||||||
|
>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
|
||||||
|
if (char === "▀") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ▄ bottom-half-lit: fg uses bottom-pixel sample
|
||||||
|
if (char === "▄") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
|
▄
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||||
{char}
|
{char}
|
||||||
</text>
|
</text>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stop()
|
|
||||||
hum = false
|
|
||||||
Sound.dispose()
|
|
||||||
})
|
|
||||||
|
|
||||||
const mouse = (evt: MouseEvent) => {
|
const mouse = (evt: MouseEvent) => {
|
||||||
if (!box) return
|
if (!box) return
|
||||||
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||||
@@ -613,17 +856,28 @@ export function Logo() {
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
top={0}
|
top={0}
|
||||||
left={0}
|
left={0}
|
||||||
width={FULL[0]?.length ?? 0}
|
width={ctx.FULL[0]?.length ?? 0}
|
||||||
height={FULL.length}
|
height={ctx.FULL.length}
|
||||||
zIndex={1}
|
zIndex={1}
|
||||||
onMouse={mouse}
|
onMouse={mouse}
|
||||||
/>
|
/>
|
||||||
<For each={logo.left}>
|
<For each={ctx.shape.left}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
|
||||||
<box flexDirection="row">
|
<box flexDirection="row">
|
||||||
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row">
|
||||||
|
{renderLine(
|
||||||
|
ctx.shape.right[index()],
|
||||||
|
index(),
|
||||||
|
props.ink ?? theme.text,
|
||||||
|
true,
|
||||||
|
ctx.LEFT + GAP,
|
||||||
|
frame(),
|
||||||
|
dusk(),
|
||||||
|
idleState(),
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -631,3 +885,9 @@ export function Logo() {
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoLogo() {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const base = tint(theme.background, theme.text, 0.62)
|
||||||
|
return <Logo shape={go} ink={base} idle />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { useLocal } from "@tui/context/local"
|
import { useLocal } from "@tui/context/local"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { tint, useTheme } from "@tui/context/theme"
|
||||||
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||||
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 { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { useEvent } from "@tui/context/event"
|
import { useEvent } from "@tui/context/event"
|
||||||
import { MessageID, PartID } from "@/session/schema"
|
import { MessageID, PartID } from "@/session/schema"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
import { usePromptHistory, type PromptInfo } from "./history"
|
import { usePromptHistory, type PromptInfo } from "./history"
|
||||||
import { assign } from "./part"
|
import { assign } from "./part"
|
||||||
@@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
|
|||||||
import { DialogAlert } from "../../ui/dialog-alert"
|
import { DialogAlert } from "../../ui/dialog-alert"
|
||||||
import { useToast } from "../../ui/toast"
|
import { useToast } from "../../ui/toast"
|
||||||
import { useKV } from "../../context/kv"
|
import { useKV } from "../../context/kv"
|
||||||
|
import { createFadeIn } from "../../util/signal"
|
||||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||||
import { DialogSkill } from "../dialog-skill"
|
import { DialogSkill } from "../dialog-skill"
|
||||||
|
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||||
|
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||||
import { useArgs } from "@tui/context/args"
|
import { useArgs } from "@tui/context/args"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
@@ -75,6 +79,12 @@ function randomIndex(count: number) {
|
|||||||
return Math.floor(Math.random() * count)
|
return Math.floor(Math.random() * count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fadeColor(color: RGBA, alpha: number) {
|
||||||
|
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||||
|
|
||||||
export function Prompt(props: PromptProps) {
|
export function Prompt(props: PromptProps) {
|
||||||
let input: TextareaRenderable
|
let input: TextareaRenderable
|
||||||
let anchor: BoxRenderable
|
let anchor: BoxRenderable
|
||||||
@@ -85,6 +95,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const { theme, syntax } = useTheme()
|
const { theme, syntax } = useTheme()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
|
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||||
@@ -233,9 +245,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
keybind: "input_submit",
|
keybind: "input_submit",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: (dialog) => {
|
onSelect: async (dialog) => {
|
||||||
if (!input.focused) return
|
if (!input.focused) return
|
||||||
void submit()
|
const handled = await submit()
|
||||||
|
if (!handled) return
|
||||||
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const saved = stashed
|
||||||
|
stashed = undefined
|
||||||
|
if (store.prompt.input) return
|
||||||
|
if (saved && saved.prompt.input) {
|
||||||
|
input.setText(saved.prompt.input)
|
||||||
|
setStore("prompt", saved.prompt)
|
||||||
|
restoreExtmarksFromParts(saved.prompt.parts)
|
||||||
|
input.cursorOffset = saved.cursor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (store.prompt.input) {
|
||||||
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||||
|
}
|
||||||
props.ref?.(undefined)
|
props.ref?.(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
if (props.visible === false || dialog.stack.length > 0) {
|
if (props.visible === false || dialog.stack.length > 0) {
|
||||||
input.blur()
|
if (input.focused) input.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slot/plugin updates can remount the background prompt while a dialog is open.
|
// Slot/plugin updates can remount the background prompt while a dialog is open.
|
||||||
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
|
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
|
||||||
input.focus()
|
if (!input.focused) input.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
if (!input || input.isDestroyed) return
|
||||||
|
const capture =
|
||||||
|
store.mode === "normal"
|
||||||
|
? auto()?.visible
|
||||||
|
? (["escape", "navigate", "submit", "tab"] as const)
|
||||||
|
: (["tab"] as const)
|
||||||
|
: undefined
|
||||||
input.traits = {
|
input.traits = {
|
||||||
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
|
capture,
|
||||||
suspend: !!props.disabled || store.mode === "shell",
|
suspend: !!props.disabled || store.mode === "shell",
|
||||||
status: store.mode === "shell" ? "SHELL" : undefined,
|
status: store.mode === "shell" ? "SHELL" : undefined,
|
||||||
}
|
}
|
||||||
@@ -599,20 +634,48 @@ export function Prompt(props: PromptProps) {
|
|||||||
setStore("prompt", "input", input.plainText)
|
setStore("prompt", "input", input.plainText)
|
||||||
syncExtmarksWithPromptParts()
|
syncExtmarksWithPromptParts()
|
||||||
}
|
}
|
||||||
if (props.disabled) return
|
if (props.disabled) return false
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return false
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return false
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return
|
if (!agent) return false
|
||||||
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()
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
const selectedModel = local.model.current()
|
const selectedModel = local.model.current()
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
void promptModelWarning()
|
void promptModelWarning()
|
||||||
return
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
|
||||||
|
const workspaceID = workspaceSession?.workspaceID
|
||||||
|
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
|
||||||
|
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogWorkspaceUnavailable
|
||||||
|
onRestore={() => {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogWorkspaceCreate
|
||||||
|
onSelect={(nextWorkspaceID) =>
|
||||||
|
restoreWorkspaceSession({
|
||||||
|
dialog,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
project,
|
||||||
|
toast,
|
||||||
|
workspaceID: nextWorkspaceID,
|
||||||
|
sessionID: props.sessionID!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
@@ -627,7 +690,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID = res.data.id
|
sessionID = res.data.id
|
||||||
@@ -741,6 +804,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
}, 50)
|
}, 50)
|
||||||
input.clear()
|
input.clear()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
|
|
||||||
@@ -841,6 +905,14 @@ export function Prompt(props: PromptProps) {
|
|||||||
return !!current
|
return !!current
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
|
||||||
|
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
|
||||||
|
const variantMetaAlpha = createFadeIn(
|
||||||
|
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
|
||||||
|
animationsEnabled,
|
||||||
|
)
|
||||||
|
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
|
||||||
|
|
||||||
const placeholderText = createMemo(() => {
|
const placeholderText = createMemo(() => {
|
||||||
if (props.showPlaceholder === false) return undefined
|
if (props.showPlaceholder === false) return undefined
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
@@ -901,7 +973,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={highlight()}
|
borderColor={borderHighlight()}
|
||||||
customBorderChars={{
|
customBorderChars={{
|
||||||
...SplitBorder.customBorderChars,
|
...SplitBorder.customBorderChars,
|
||||||
bottomLeft: "╹",
|
bottomLeft: "╹",
|
||||||
@@ -1116,17 +1188,25 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Show when={local.agent.current()} fallback={<box height={1} />}>
|
<Show when={local.agent.current()} fallback={<box height={1} />}>
|
||||||
{(agent) => (
|
{(agent) => (
|
||||||
<>
|
<>
|
||||||
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
|
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
|
||||||
|
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
|
||||||
|
</text>
|
||||||
<Show when={store.mode === "normal"}>
|
<Show when={store.mode === "normal"}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
|
||||||
|
>
|
||||||
{local.model.parsed().model}
|
{local.model.parsed().model}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
|
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||||
<Show when={showVariant()}>
|
<Show when={showVariant()}>
|
||||||
<text fg={theme.textMuted}>·</text>
|
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
|
||||||
|
{local.model.variant.current()}
|
||||||
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -1146,7 +1226,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
<box
|
<box
|
||||||
height={1}
|
height={1}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={highlight()}
|
borderColor={borderHighlight()}
|
||||||
customBorderChars={{
|
customBorderChars={{
|
||||||
...EmptyBorder,
|
...EmptyBorder,
|
||||||
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const TuiLegacy = z
|
|||||||
interface MigrateInput {
|
interface MigrateInput {
|
||||||
cwd: string
|
cwd: string
|
||||||
directories: string[]
|
directories: string[]
|
||||||
custom?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
||||||
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
|
const files = [
|
||||||
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
|
...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
|
||||||
|
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
|
||||||
|
]
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,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: ConfigPlugin.Spec.array().optional(),
|
plugin: ConfigPlugin.Spec.zod.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)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * as TuiConfig from "./tui"
|
||||||
|
|
||||||
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"
|
||||||
@@ -9,51 +11,47 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
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 { CurrentWorkingDirectory } from "./cwd"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { ConfigKeybinds } from "@/config/keybinds"
|
import { ConfigKeybinds } from "@/config/keybinds"
|
||||||
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||||
import { makeRuntime } from "@/cli/effect/runtime"
|
import { makeRuntime } from "@/effect/runtime"
|
||||||
import { Filesystem, Log } from "@/util"
|
import { Filesystem, Log } from "@/util"
|
||||||
|
import { ConfigVariable } from "@/config/variable"
|
||||||
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
export namespace TuiConfig {
|
const log = Log.create({ service: "tui.config" })
|
||||||
const log = Log.create({ service: "tui.config" })
|
|
||||||
|
|
||||||
export const Info = TuiInfo
|
export const Info = TuiInfo
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
result: Info
|
result: Info
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
config: Info
|
config: Info
|
||||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||||
}
|
}
|
||||||
|
|
||||||
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?: ConfigPlugin.Origin[]
|
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>
|
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 }): ConfigPlugin.Scope {
|
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||||
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
}
|
}
|
||||||
|
|
||||||
function customPath() {
|
function normalize(raw: Record<string, unknown>) {
|
||||||
return Flag.OPENCODE_TUI_CONFIG
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(raw: Record<string, unknown>) {
|
|
||||||
const data = { ...raw }
|
const data = { ...raw }
|
||||||
if (!("tui" in data)) return data
|
if (!("tui" in data)) return data
|
||||||
if (!isRecord(data.tui)) {
|
if (!isRecord(data.tui)) {
|
||||||
@@ -67,17 +65,17 @@ export namespace TuiConfig {
|
|||||||
...tui,
|
...tui,
|
||||||
...data,
|
...data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePlugins(config: Info, configFilepath: string) {
|
async function resolvePlugins(config: Info, configFilepath: string) {
|
||||||
if (!config.plugin) return config
|
if (!config.plugin) return config
|
||||||
for (let i = 0; i < config.plugin.length; i++) {
|
for (let i = 0; i < config.plugin.length; i++) {
|
||||||
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
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
|
||||||
@@ -89,39 +87,46 @@ export namespace TuiConfig {
|
|||||||
])
|
])
|
||||||
acc.result.plugin = plugins.map((item) => item.spec)
|
acc.result.plugin = plugins.map((item) => item.spec)
|
||||||
acc.result.plugin_origins = plugins
|
acc.result.plugin_origins = plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadState(ctx: { directory: string }) {
|
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
// Every config dir we may read from: global config dir, any `.opencode`
|
||||||
const directories = await ConfigPaths.directories(ctx.directory)
|
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||||
const custom = customPath()
|
const directories = yield* ConfigPaths.directories(ctx.directory)
|
||||||
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
|
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
|
||||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
|
||||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
|
||||||
|
|
||||||
const acc: Acc = {
|
const acc: Acc = {
|
||||||
result: {},
|
result: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Global tui config (lowest precedence).
|
||||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||||
await mergeFile(acc, file, ctx)
|
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (custom) {
|
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
||||||
await mergeFile(acc, custom, ctx)
|
if (Flag.OPENCODE_TUI_CONFIG) {
|
||||||
log.debug("loaded custom tui config", { path: custom })
|
const configFile = Flag.OPENCODE_TUI_CONFIG
|
||||||
|
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
|
||||||
|
log.debug("loaded custom tui config", { path: configFile })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Project tui files, applied root-first so the closest file wins.
|
||||||
for (const file of projectFiles) {
|
for (const file of projectFiles) {
|
||||||
await mergeFile(acc, file, ctx)
|
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
|
||||||
|
// walking up the tree. Also returned below so callers can install plugin
|
||||||
|
// dependencies from each location.
|
||||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||||
await mergeFile(acc, file, ctx)
|
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,20 +145,25 @@ export namespace TuiConfig {
|
|||||||
config: acc.result,
|
config: acc.result,
|
||||||
dirs: acc.result.plugin?.length ? dirs : [],
|
dirs: acc.result.plugin?.length ? dirs : [],
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = yield* CurrentWorkingDirectory
|
const directory = yield* CurrentWorkingDirectory
|
||||||
const npm = yield* Npm.Service
|
const npm = yield* Npm.Service
|
||||||
const data = yield* Effect.promise(() => loadState({ directory }))
|
const data = yield* loadState({ directory })
|
||||||
const deps = yield* Effect.forEach(
|
const deps = yield* Effect.forEach(
|
||||||
data.dirs,
|
data.dirs,
|
||||||
(dir) =>
|
(dir) =>
|
||||||
npm
|
npm
|
||||||
.install(dir, {
|
.install(dir, {
|
||||||
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
add: [
|
||||||
|
{
|
||||||
|
name: "@opencode-ai/plugin",
|
||||||
|
version: InstallationLocal ? undefined : InstallationVersion,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkScoped),
|
.pipe(Effect.forkScoped),
|
||||||
{
|
{
|
||||||
@@ -168,46 +178,42 @@ export namespace TuiConfig {
|
|||||||
)
|
)
|
||||||
return Service.of({ get, waitForDependencies })
|
return Service.of({ get, waitForDependencies })
|
||||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function waitForDependencies() {
|
export async function waitForDependencies() {
|
||||||
await runPromise((svc) => svc.waitForDependencies())
|
await runPromise((svc) => svc.waitForDependencies())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get() {
|
export async function get() {
|
||||||
return runPromise((svc) => svc.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 {}
|
||||||
return load(text, filepath).catch((error) => {
|
return load(text, filepath).catch((error) => {
|
||||||
log.warn("failed to load tui config", { path: filepath, error })
|
log.warn("failed to load tui config", { path: filepath, error })
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
return ConfigParse.load(Info, text, {
|
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
|
||||||
type: "path",
|
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
|
||||||
path: configFilepath,
|
.then((data) => {
|
||||||
missing: "empty",
|
|
||||||
normalize: (data) => {
|
|
||||||
if (!isRecord(data)) return {}
|
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.
|
||||||
return normalize(data)
|
return ConfigParse.schema(Info, normalize(data), configFilepath)
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then((data) => resolvePlugins(data, configFilepath))
|
.then((data) => resolvePlugins(data, configFilepath))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log.warn("invalid tui config", { path: configFilepath, error })
|
log.warn("invalid tui config", { path: configFilepath, error })
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<Record<string, any>>()
|
const [store, setStore] = createStore<Record<string, any>>()
|
||||||
const filePath = path.join(Global.Path.state, "kv.json")
|
const filePath = path.join(Global.Path.state, "kv.json")
|
||||||
|
|
||||||
Filesystem.readJson(filePath)
|
Filesystem.readJson<Record<string, any>>(filePath)
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
setStore(x)
|
setStore(x)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
move(direction: 1 | -1) {
|
move(direction: 1 | -1) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
|
const current = this.current()
|
||||||
|
if (!current) return
|
||||||
|
let next = agents().findIndex((x) => x.name === current.name) + direction
|
||||||
if (next < 0) next = agents().length - 1
|
if (next < 0) next = agents().length - 1
|
||||||
if (next >= agents().length) next = 0
|
if (next >= agents().length) next = 0
|
||||||
const value = agents()[next]
|
const value = agents()[next]
|
||||||
|
|||||||
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
|||||||
name: "Project",
|
name: "Project",
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const [store, setStore] = createStore({
|
|
||||||
project: {
|
const defaultPath = {
|
||||||
id: undefined as string | undefined,
|
|
||||||
},
|
|
||||||
instance: {
|
|
||||||
path: {
|
|
||||||
home: "",
|
home: "",
|
||||||
state: "",
|
state: "",
|
||||||
config: "",
|
config: "",
|
||||||
worktree: "",
|
worktree: "",
|
||||||
directory: sdk.directory ?? "",
|
directory: sdk.directory ?? "",
|
||||||
} satisfies Path,
|
} satisfies Path
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: {
|
||||||
|
id: undefined as string | undefined,
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
path: defaultPath,
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
current: undefined as string | undefined,
|
current: undefined as string | undefined,
|
||||||
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
|||||||
])
|
])
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("instance", "path", reconcile(path.data!))
|
setStore("instance", "path", reconcile(path.data || defaultPath))
|
||||||
setStore("project", "id", project.data?.id)
|
setStore("project", "id", project.data?.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import type { PromptInfo } from "../component/prompt/history"
|
import type { PromptInfo } from "../component/prompt/history"
|
||||||
|
|
||||||
export type HomeRoute = {
|
export type HomeRoute = {
|
||||||
type: "home"
|
type: "home"
|
||||||
initialPrompt?: PromptInfo
|
prompt?: PromptInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionRoute = {
|
export type SessionRoute = {
|
||||||
type: "session"
|
type: "session"
|
||||||
sessionID: string
|
sessionID: string
|
||||||
initialPrompt?: PromptInfo
|
prompt?: PromptInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginRoute = {
|
export type PluginRoute = {
|
||||||
@@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
|
|||||||
|
|
||||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||||
name: "Route",
|
name: "Route",
|
||||||
init: () => {
|
init: (props: { initialRoute?: Route }) => {
|
||||||
const [store, setStore] = createStore<Route>(
|
const [store, setStore] = createStore<Route>(
|
||||||
process.env["OPENCODE_ROUTE"]
|
props.initialRoute ??
|
||||||
|
(process.env["OPENCODE_ROUTE"]
|
||||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||||
: {
|
: {
|
||||||
type: "home",
|
type: "home",
|
||||||
},
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
|||||||
return store
|
return store
|
||||||
},
|
},
|
||||||
navigate(route: Route) {
|
navigate(route: Route) {
|
||||||
setStore(route)
|
setStore(reconcile(route))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|||||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
import { batch, onCleanup, onMount } from "solid-js"
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
|
||||||
export type EventSource = {
|
export type EventSource = {
|
||||||
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
let queue: GlobalEvent[] = []
|
let queue: GlobalEvent[] = []
|
||||||
let timer: Timer | undefined
|
let timer: Timer | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
const retryDelay = 1000
|
||||||
|
const maxRetryDelay = 30000
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (queue.length === 0) return
|
if (queue.length === 0) return
|
||||||
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
sse = ctrl
|
sse = ctrl
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
let attempt = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
|
||||||
|
const events = await sdk.global.event({
|
||||||
|
signal: ctrl.signal,
|
||||||
|
sseMaxRetryAttempts: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
// Start syncing workspaces, it's important to do this after
|
||||||
|
// we've started listening to events
|
||||||
|
await sdk.sync.start().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
if (ctrl.signal.aborted) break
|
if (ctrl.signal.aborted) break
|
||||||
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
|
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
if (queue.length > 0) flush()
|
if (queue.length > 0) flush()
|
||||||
|
attempt += 1
|
||||||
|
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||||
}
|
}
|
||||||
})().catch(() => {})
|
})().catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
if (props.events) {
|
if (props.events) {
|
||||||
const unsub = await props.events.subscribe(handleEvent)
|
const unsub = await props.events.subscribe(handleEvent)
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
// Start syncing workspaces, it's important to do this after
|
||||||
|
// we've started listening to events
|
||||||
|
await sdk.sync.start().catch(() => {})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
startSSE()
|
startSSE()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
|
|||||||
import type { Snapshot } from "@/snapshot"
|
import type { Snapshot } from "@/snapshot"
|
||||||
import { useExit } from "./exit"
|
import { useExit } from "./exit"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
import { batch, createEffect, on } from "solid-js"
|
import { batch, onMount } from "solid-js"
|
||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||||
|
|
||||||
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const project = useProject()
|
const project = useProject()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
|
const fullSyncedSessions = new Set<string>()
|
||||||
|
let syncedWorkspace = project.workspace.current()
|
||||||
|
|
||||||
event.subscribe((event) => {
|
event.subscribe((event) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed":
|
case "server.instance.disposed":
|
||||||
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||||
console.log("bootstrapping")
|
const fatal = input.fatal ?? true
|
||||||
const workspace = project.workspace.current()
|
const workspace = project.workspace.current()
|
||||||
|
if (workspace !== syncedWorkspace) {
|
||||||
|
fullSyncedSessions.clear()
|
||||||
|
syncedWorkspace = workspace
|
||||||
|
}
|
||||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
const sessionListPromise = sdk.client.session
|
const sessionListPromise = sdk.client.session
|
||||||
.list({ start: start })
|
.list({ start: start })
|
||||||
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
name: e instanceof Error ? e.name : undefined,
|
name: e instanceof Error ? e.name : undefined,
|
||||||
stack: e instanceof Error ? e.stack : undefined,
|
stack: e instanceof Error ? e.stack : undefined,
|
||||||
})
|
})
|
||||||
|
if (fatal) {
|
||||||
await exit(e)
|
await exit(e)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullSyncedSessions = new Set<string>()
|
onMount(() => {
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => project.workspace.current(),
|
|
||||||
() => {
|
|
||||||
fullSyncedSessions.clear()
|
|
||||||
void bootstrap()
|
void bootstrap()
|
||||||
},
|
})
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
data: store,
|
data: store,
|
||||||
@@ -463,6 +467,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return store.status
|
return store.status
|
||||||
},
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
|
return true
|
||||||
|
if (process.env.OPENCODE_FAST_BOOT) return true
|
||||||
return store.status !== "loading"
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -492,7 +498,6 @@ 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 [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
if (store.lock) return
|
if (store.lock) return
|
||||||
apply(mode)
|
apply(mode)
|
||||||
}
|
}
|
||||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
// renderer.on(CliRenderEvents.THEME_MODE, handle)
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
renderer.clearPaletteCache()
|
renderer.clearPaletteCache()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { TuiConfig } from "./config/tui"
|
import { TuiConfig } from "./config/tui"
|
||||||
import { Npm } from "@opencode-ai/shared/npm"
|
import { Npm } from "@/npm"
|
||||||
import { Observability } from "@/effect/observability"
|
import { Observability } from "@/effect/observability"
|
||||||
|
|
||||||
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
|
|||||||
name: "session",
|
name: "session",
|
||||||
params: {
|
params: {
|
||||||
sessionID: route.data.sessionID,
|
sessionID: route.data.sessionID,
|
||||||
initialPrompt: route.data.initialPrompt,
|
prompt: route.data.prompt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -918,13 +918,12 @@ async function installPluginBySpec(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace TuiPluginRuntime {
|
let dir = ""
|
||||||
let dir = ""
|
let loaded: Promise<void> | undefined
|
||||||
let loaded: Promise<void> | undefined
|
let runtime: RuntimeState | undefined
|
||||||
let runtime: RuntimeState | undefined
|
export const Slot = View
|
||||||
export const Slot = View
|
|
||||||
|
|
||||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
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,30 +935,30 @@ export namespace TuiPluginRuntime {
|
|||||||
dir = cwd
|
dir = cwd
|
||||||
loaded = load(input)
|
loaded = load(input)
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
export function list() {
|
export function list() {
|
||||||
if (!runtime) return []
|
if (!runtime) return []
|
||||||
return listPluginStatus(runtime)
|
return listPluginStatus(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activatePlugin(id: string) {
|
export async function activatePlugin(id: string) {
|
||||||
return activatePluginById(runtime, id, true)
|
return activatePluginById(runtime, id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivatePlugin(id: string) {
|
export async function deactivatePlugin(id: string) {
|
||||||
return deactivatePluginById(runtime, id, true)
|
return deactivatePluginById(runtime, id, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPlugin(spec: string) {
|
export async function addPlugin(spec: string) {
|
||||||
return addPluginBySpec(runtime, spec)
|
return addPluginBySpec(runtime, spec)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||||
return installPluginBySpec(runtime, spec, options?.global)
|
return installPluginBySpec(runtime, spec, options?.global)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispose() {
|
export async function dispose() {
|
||||||
const task = loaded
|
const task = loaded
|
||||||
loaded = undefined
|
loaded = undefined
|
||||||
dir = ""
|
dir = ""
|
||||||
@@ -971,9 +970,9 @@ export namespace TuiPluginRuntime {
|
|||||||
for (const plugin of queue) {
|
for (const plugin of queue) {
|
||||||
await deactivatePluginEntry(state, plugin, false)
|
await deactivatePluginEntry(state, plugin, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||||
const { api, config } = input
|
const { api, config } = input
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const slots = setupSlots(api)
|
const slots = setupSlots(api)
|
||||||
@@ -1026,5 +1025,6 @@ export namespace TuiPluginRuntime {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail("failed to load tui plugins", { directory: cwd, error })
|
fail("failed to load tui plugins", { directory: cwd, error })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * as TuiPluginRuntime from "./runtime"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
|
|||||||
import { useLocal } from "../context/local"
|
import { useLocal } from "../context/local"
|
||||||
import { TuiPluginRuntime } from "../plugin"
|
import { TuiPluginRuntime } from "../plugin"
|
||||||
|
|
||||||
// TODO: what is the best way to do this?
|
|
||||||
let once = false
|
let once = false
|
||||||
const placeholder = {
|
const placeholder = {
|
||||||
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
|
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
|
||||||
@@ -31,8 +30,8 @@ export function Home() {
|
|||||||
setRef(r)
|
setRef(r)
|
||||||
promptRef.set(r)
|
promptRef.set(r)
|
||||||
if (once || !r) return
|
if (once || !r) return
|
||||||
if (route.initialPrompt) {
|
if (route.prompt) {
|
||||||
r.set(route.initialPrompt)
|
r.set(route.prompt)
|
||||||
once = true
|
once = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
})
|
})
|
||||||
const parts = sync.data.part[message.id] ?? []
|
const parts = sync.data.part[message.id] ?? []
|
||||||
const initialPrompt = parts.reduce(
|
const prompt = parts.reduce(
|
||||||
(agg, part) => {
|
(agg, part) => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
if (!part.synthetic) agg.input += part.text
|
if (!part.synthetic) agg.input += part.text
|
||||||
@@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||||||
route.navigate({
|
route.navigate({
|
||||||
sessionID: forked.data!.id,
|
sessionID: forked.data!.id,
|
||||||
type: "session",
|
type: "session",
|
||||||
initialPrompt,
|
prompt,
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,11 +81,9 @@ export function DialogMessage(props: {
|
|||||||
sessionID: props.sessionID,
|
sessionID: props.sessionID,
|
||||||
messageID: props.messageID,
|
messageID: props.messageID,
|
||||||
})
|
})
|
||||||
const initialPrompt = (() => {
|
|
||||||
const msg = message()
|
const msg = message()
|
||||||
if (!msg) return undefined
|
const prompt = msg
|
||||||
const parts = sync.data.part[msg.id]
|
? sync.data.part[msg.id].reduce(
|
||||||
return parts.reduce(
|
|
||||||
(agg, part) => {
|
(agg, part) => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
if (!part.synthetic) agg.input += part.text
|
if (!part.synthetic) agg.input += part.text
|
||||||
@@ -95,11 +93,11 @@ export function DialogMessage(props: {
|
|||||||
},
|
},
|
||||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||||
)
|
)
|
||||||
})()
|
: undefined
|
||||||
route.navigate({
|
route.navigate({
|
||||||
sessionID: result.data!.id,
|
sessionID: result.data!.id,
|
||||||
type: "session",
|
type: "session",
|
||||||
initialPrompt,
|
prompt,
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { useSDK } from "@tui/context/sdk"
|
|||||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||||
import type { DialogContext } from "@tui/ui/dialog"
|
import type { DialogContext } from "@tui/ui/dialog"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
import { parsePatch } from "diff"
|
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
@@ -88,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
|||||||
import { TuiPluginRuntime } from "../../plugin"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
||||||
import { SessionRetry } from "@/session/retry"
|
import { SessionRetry } from "@/session/retry"
|
||||||
|
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -181,27 +181,32 @@ export function Session() {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
await sdk.client.session
|
const previousWorkspace = project.workspace.current()
|
||||||
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||||
.then((x) => {
|
if (!result.data) {
|
||||||
project.workspace.set(x.data?.workspaceID)
|
|
||||||
})
|
|
||||||
.then(() => sync.session.sync(route.sessionID))
|
|
||||||
.then(() => {
|
|
||||||
if (scroll) scroll.scrollBy(100_000)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e)
|
|
||||||
toast.show({
|
toast.show({
|
||||||
message: `Session not found: ${route.sessionID}`,
|
message: `Session not found: ${route.sessionID}`,
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return navigate({ type: "home" })
|
navigate({ type: "home" })
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.workspaceID !== previousWorkspace) {
|
||||||
|
project.workspace.set(result.data.workspaceID)
|
||||||
|
|
||||||
|
// Sync all the data for this workspace. Note that this
|
||||||
|
// workspace may not exist anymore which is why this is not
|
||||||
|
// fatal. If it doesn't we still want to show the session
|
||||||
|
// (which will be non-interactive)
|
||||||
|
try {
|
||||||
|
await sync.bootstrap({ fatal: false })
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
await sync.session.sync(route.sessionID)
|
||||||
|
if (scroll) scroll.scrollBy(100_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle initial prompt from fork
|
|
||||||
let seeded = false
|
|
||||||
let lastSwitch: string | undefined = undefined
|
let lastSwitch: string | undefined = undefined
|
||||||
event.on("message.part.updated", (evt) => {
|
event.on("message.part.updated", (evt) => {
|
||||||
const part = evt.properties.part
|
const part = evt.properties.part
|
||||||
@@ -219,14 +224,15 @@ export function Session() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let seeded = false
|
||||||
let scroll: ScrollBoxRenderable
|
let scroll: ScrollBoxRenderable
|
||||||
let prompt: PromptRef | undefined
|
let prompt: PromptRef | undefined
|
||||||
const bind = (r: PromptRef | undefined) => {
|
const bind = (r: PromptRef | undefined) => {
|
||||||
prompt = r
|
prompt = r
|
||||||
promptRef.set(r)
|
promptRef.set(r)
|
||||||
if (seeded || !route.initialPrompt || !r) return
|
if (seeded || !route.prompt || !r) return
|
||||||
seeded = true
|
seeded = true
|
||||||
r.set(route.initialPrompt)
|
r.set(route.prompt)
|
||||||
}
|
}
|
||||||
const keybind = useKeybind()
|
const keybind = useKeybind()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -991,31 +997,7 @@ export function Session() {
|
|||||||
const revertInfo = createMemo(() => session()?.revert)
|
const revertInfo = createMemo(() => session()?.revert)
|
||||||
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
const revertMessageID = createMemo(() => revertInfo()?.messageID)
|
||||||
|
|
||||||
const revertDiffFiles = createMemo(() => {
|
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
|
||||||
const diffText = revertInfo()?.diff ?? ""
|
|
||||||
if (!diffText) return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const patches = parsePatch(diffText)
|
|
||||||
return patches.map((patch) => {
|
|
||||||
const filename = patch.newFileName || patch.oldFileName || "unknown"
|
|
||||||
const cleanFilename = filename.replace(/^[ab]\//, "")
|
|
||||||
return {
|
|
||||||
filename: cleanFilename,
|
|
||||||
additions: patch.hunks.reduce(
|
|
||||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
deletions: patch.hunks.reduce(
|
|
||||||
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const revertRevertedMessages = createMemo(() => {
|
const revertRevertedMessages = createMemo(() => {
|
||||||
const messageID = revertMessageID()
|
const messageID = revertMessageID()
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSync } from "@tui/context/sync"
|
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 { InstallationVersion } from "@/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||||
import { TuiPluginRuntime } from "../../plugin"
|
import { TuiPluginRuntime } from "../../plugin"
|
||||||
|
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
|
|
||||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||||
|
const project = useProject()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const session = createMemo(() => sync.session.get(props.sessionID))
|
const session = createMemo(() => sync.session.get(props.sessionID))
|
||||||
|
const workspaceStatus = () => {
|
||||||
|
const workspaceID = session()?.workspaceID
|
||||||
|
if (!workspaceID) return "error"
|
||||||
|
return project.workspace.status(workspaceID) ?? "error"
|
||||||
|
}
|
||||||
|
const workspaceLabel = () => {
|
||||||
|
const workspaceID = session()?.workspaceID
|
||||||
|
if (!workspaceID) return "unknown"
|
||||||
|
const info = project.workspace.get(workspaceID)
|
||||||
|
if (!info) return "unknown"
|
||||||
|
return `${info.type}: ${info.name}`
|
||||||
|
}
|
||||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<b>{session()!.title}</b>
|
<b>{session()!.title}</b>
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={InstallationChannel !== "latest"}>
|
||||||
|
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={session()!.workspaceID}>
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||||
|
{workspaceLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<Show when={session()!.share?.url}>
|
<Show when={session()!.share?.url}>
|
||||||
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
<text fg={theme.textMuted}>{session()!.share!.url}</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
|
|||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
import { writeHeapSnapshot } from "v8"
|
import { writeHeapSnapshot } from "v8"
|
||||||
import { TuiConfig } from "./config/tui"
|
import { TuiConfig } from "./config/tui"
|
||||||
|
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cwd = Filesystem.resolve(process.cwd())
|
const cwd = Filesystem.resolve(process.cwd())
|
||||||
|
const env = sanitizedProcessEnv({
|
||||||
|
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||||
|
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||||
|
})
|
||||||
|
|
||||||
const worker = new Worker(file, {
|
const worker = new Worker(file, {
|
||||||
env: Object.fromEntries(
|
env,
|
||||||
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
worker.onerror = (e) => {
|
worker.onerror = (e) => {
|
||||||
Log.Default.error("thread error", {
|
Log.Default.error("thread error", {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { parsePatch } from "diff"
|
||||||
|
|
||||||
|
export function getRevertDiffFiles(diffText: string) {
|
||||||
|
if (!diffText) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parsePatch(diffText).map((patch) => {
|
||||||
|
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
|
||||||
|
return {
|
||||||
|
filename: filename.replace(/^[ab]\//, ""),
|
||||||
|
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
|
||||||
|
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,41 @@
|
|||||||
import { createSignal, type Accessor } from "solid-js"
|
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||||
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
|
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
|
||||||
|
|
||||||
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
|
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
|
||||||
const [get, set] = createSignal(value)
|
const [get, set] = createSignal(value)
|
||||||
return [get, debounce((v: T) => set(() => v), ms)]
|
return [get, debounce((v: T) => set(() => v), ms)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
|
||||||
|
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
|
||||||
|
let revealed = show()
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on([show, enabled], ([visible, animate]) => {
|
||||||
|
if (!visible) {
|
||||||
|
setAlpha(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!animate || revealed) {
|
||||||
|
revealed = true
|
||||||
|
setAlpha(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now()
|
||||||
|
revealed = true
|
||||||
|
setAlpha(0)
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const progress = Math.min((performance.now() - start) / 160, 1)
|
||||||
|
setAlpha(progress * progress * (3 - 2 * progress))
|
||||||
|
if (progress >= 1) clearInterval(timer)
|
||||||
|
}, 16)
|
||||||
|
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { writeHeapSnapshot } from "node:v8"
|
import { writeHeapSnapshot } from "node:v8"
|
||||||
import { Heap } from "@/cli/heap"
|
import { Heap } from "@/cli/heap"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { ensureProcessMetadata } from "@/util/opencode-process"
|
||||||
|
|
||||||
|
ensureProcessMetadata("worker")
|
||||||
|
|
||||||
await Log.init({
|
await Log.init({
|
||||||
print: process.argv.includes("--print-logs"),
|
print: process.argv.includes("--print-logs"),
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
|
|||||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||||
const data = (input as ErrorLike).data
|
const data = (input as ErrorLike).data
|
||||||
const suggestions = data?.suggestions as string[] | undefined
|
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
|
||||||
return [
|
return [
|
||||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(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")
|
||||||
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
|
|||||||
const data = (input as ErrorLike).data
|
const data = (input as ErrorLike).data
|
||||||
const path = data?.path
|
const path = data?.path
|
||||||
const message = data?.message
|
const message = data?.message
|
||||||
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
|
||||||
return [
|
return [
|
||||||
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||||
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ const log = Log.create({ service: "heap" })
|
|||||||
const MINUTE = 60_000
|
const MINUTE = 60_000
|
||||||
const LIMIT = 2 * 1024 * 1024 * 1024
|
const LIMIT = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
export namespace Heap {
|
let timer: Timer | undefined
|
||||||
let timer: Timer | undefined
|
let lock = false
|
||||||
let lock = false
|
let armed = true
|
||||||
let armed = true
|
|
||||||
|
|
||||||
export function start() {
|
export function start() {
|
||||||
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
|
if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return
|
||||||
if (timer) return
|
if (timer) return
|
||||||
|
|
||||||
@@ -55,5 +54,6 @@ export namespace Heap {
|
|||||||
void run()
|
void run()
|
||||||
}, MINUTE)
|
}, MINUTE)
|
||||||
timer.unref?.()
|
timer.unref?.()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * as Heap from "./heap"
|
||||||
|
|||||||
@@ -3,4 +3,9 @@ export const logo = {
|
|||||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const marks = "_^~"
|
export const go = {
|
||||||
|
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||||
|
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marks = "_^~,"
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ import { EOL } from "os"
|
|||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { logo as glyphs } from "./logo"
|
import { logo as glyphs } from "./logo"
|
||||||
|
|
||||||
export namespace UI {
|
const wordmark = [
|
||||||
const wordmark = [
|
|
||||||
`⠀ ▄ `,
|
`⠀ ▄ `,
|
||||||
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
|
||||||
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
|
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
|
||||||
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
export const CancelledError = NamedError.create("UICancelledError", z.void())
|
||||||
|
|
||||||
export const Style = {
|
export const Style = {
|
||||||
TEXT_HIGHLIGHT: "\x1b[96m",
|
TEXT_HIGHLIGHT: "\x1b[96m",
|
||||||
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
|
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
|
||||||
TEXT_DIM: "\x1b[90m",
|
TEXT_DIM: "\x1b[90m",
|
||||||
@@ -28,26 +27,26 @@ export namespace UI {
|
|||||||
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
|
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
|
||||||
TEXT_INFO: "\x1b[94m",
|
TEXT_INFO: "\x1b[94m",
|
||||||
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
|
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function println(...message: string[]) {
|
export function println(...message: string[]) {
|
||||||
print(...message)
|
print(...message)
|
||||||
process.stderr.write(EOL)
|
process.stderr.write(EOL)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function print(...message: string[]) {
|
export function print(...message: string[]) {
|
||||||
blank = false
|
blank = false
|
||||||
process.stderr.write(message.join(" "))
|
process.stderr.write(message.join(" "))
|
||||||
}
|
}
|
||||||
|
|
||||||
let blank = false
|
let blank = false
|
||||||
export function empty() {
|
export function empty() {
|
||||||
if (blank) return
|
if (blank) return
|
||||||
println("" + Style.TEXT_NORMAL)
|
println("" + Style.TEXT_NORMAL)
|
||||||
blank = true
|
blank = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logo(pad?: string) {
|
export function logo(pad?: string) {
|
||||||
if (!process.stdout.isTTY && !process.stderr.isTTY) {
|
if (!process.stdout.isTTY && !process.stderr.isTTY) {
|
||||||
const result = []
|
const result = []
|
||||||
for (const row of wordmark) {
|
for (const row of wordmark) {
|
||||||
@@ -103,9 +102,9 @@ export namespace UI {
|
|||||||
result.push(EOL)
|
result.push(EOL)
|
||||||
})
|
})
|
||||||
return result.join("").trimEnd()
|
return result.join("").trimEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function input(prompt: string): Promise<string> {
|
export async function input(prompt: string): Promise<string> {
|
||||||
const readline = require("readline")
|
const readline = require("readline")
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
@@ -118,16 +117,17 @@ export namespace UI {
|
|||||||
resolve(answer.trim())
|
resolve(answer.trim())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function error(message: string) {
|
export function error(message: string) {
|
||||||
if (message.startsWith("Error: ")) {
|
if (message.startsWith("Error: ")) {
|
||||||
message = message.slice("Error: ".length)
|
message = message.slice("Error: ".length)
|
||||||
}
|
}
|
||||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||||
}
|
|
||||||
|
|
||||||
export function markdown(text: string): string {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markdown(text: string): string {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as UI from "./ui"
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import type { InstanceContext } from "@/project/instance"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { Effect, Layer, Context } from "effect"
|
|
||||||
import z from "zod"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { MCP } from "../mcp"
|
|
||||||
import { Skill } from "../skill"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
commands: Record<string, Info>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Executed: BusEvent.define(
|
|
||||||
"command.executed",
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
arguments: z.string(),
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
agent: z.string().optional(),
|
|
||||||
model: z.string().optional(),
|
|
||||||
source: z.enum(["command", "mcp", "skill"]).optional(),
|
|
||||||
// workaround for zod not supporting async functions natively so we use getters
|
|
||||||
// https://zod.dev/v4/changelog?id=zfunction
|
|
||||||
template: z.promise(z.string()).or(z.string()),
|
|
||||||
subtask: z.boolean().optional(),
|
|
||||||
hints: z.array(z.string()),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Command",
|
|
||||||
})
|
|
||||||
|
|
||||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
|
||||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
|
||||||
|
|
||||||
export function hints(template: string) {
|
|
||||||
const result: string[] = []
|
|
||||||
const numbered = template.match(/\$\d+/g)
|
|
||||||
if (numbered) {
|
|
||||||
for (const match of [...new Set(numbered)].sort()) result.push(match)
|
|
||||||
}
|
|
||||||
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = {
|
|
||||||
INIT: "init",
|
|
||||||
REVIEW: "review",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const mcp = yield* MCP.Service
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
|
|
||||||
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
const commands: Record<string, Info> = {}
|
|
||||||
|
|
||||||
commands[Default.INIT] = {
|
|
||||||
name: Default.INIT,
|
|
||||||
description: "guided AGENTS.md setup",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
|
||||||
}
|
|
||||||
commands[Default.REVIEW] = {
|
|
||||||
name: Default.REVIEW,
|
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
agent: command.agent,
|
|
||||||
model: command.model,
|
|
||||||
description: command.description,
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
return bridge.promise(
|
|
||||||
mcp
|
|
||||||
.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.map(
|
|
||||||
(template) =>
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of yield* skill.all()) {
|
|
||||||
if (commands[item.name]) continue
|
|
||||||
commands[item.name] = {
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
source: "skill",
|
|
||||||
get template() {
|
|
||||||
return item.content
|
|
||||||
},
|
|
||||||
hints: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
commands,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return s.commands[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Command.list")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Object.values(s.commands)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(MCP.defaultLayer),
|
|
||||||
Layer.provide(Skill.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,188 @@
|
|||||||
export * as Command from "./command"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import type { InstanceContext } from "@/project/instance"
|
||||||
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Effect, Layer, Context } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { MCP } from "../mcp"
|
||||||
|
import { Skill } from "../skill"
|
||||||
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
commands: Record<string, Info>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Executed: BusEvent.define(
|
||||||
|
"command.executed",
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
arguments: z.string(),
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
agent: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
source: z.enum(["command", "mcp", "skill"]).optional(),
|
||||||
|
// workaround for zod not supporting async functions natively so we use getters
|
||||||
|
// https://zod.dev/v4/changelog?id=zfunction
|
||||||
|
template: z.promise(z.string()).or(z.string()),
|
||||||
|
subtask: z.boolean().optional(),
|
||||||
|
hints: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "Command",
|
||||||
|
})
|
||||||
|
|
||||||
|
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||||
|
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||||
|
|
||||||
|
export function hints(template: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
const numbered = template.match(/\$\d+/g)
|
||||||
|
if (numbered) {
|
||||||
|
for (const match of [...new Set(numbered)].sort()) result.push(match)
|
||||||
|
}
|
||||||
|
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
INIT: "init",
|
||||||
|
REVIEW: "review",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
|
||||||
|
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const commands: Record<string, Info> = {}
|
||||||
|
|
||||||
|
commands[Default.INIT] = {
|
||||||
|
name: Default.INIT,
|
||||||
|
description: "guided AGENTS.md setup",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||||
|
},
|
||||||
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
|
}
|
||||||
|
commands[Default.REVIEW] = {
|
||||||
|
name: Default.REVIEW,
|
||||||
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
agent: command.agent,
|
||||||
|
model: command.model,
|
||||||
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
return bridge.promise(
|
||||||
|
mcp
|
||||||
|
.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(template) =>
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of yield* skill.all()) {
|
||||||
|
if (commands[item.name]) continue
|
||||||
|
commands[item.name] = {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
source: "skill",
|
||||||
|
get template() {
|
||||||
|
return item.content
|
||||||
|
},
|
||||||
|
hints: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||||
|
|
||||||
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return s.commands[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Command.list")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Object.values(s.commands)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(MCP.defaultLayer),
|
||||||
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Command from "."
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
|
|||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
model: ConfigModelID.optional(),
|
model: ConfigModelID.zod.optional(),
|
||||||
variant: z
|
variant: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export * as ConfigCommand from "./command"
|
export * as ConfigCommand from "./command"
|
||||||
|
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import z from "zod"
|
import { Schema } from "effect"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
import { configEntryNameFromPath } from "./entry-name"
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
import { InvalidError } from "./error"
|
import { InvalidError } from "./error"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
|
|||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
export const Info = z.object({
|
export const Info = Schema.Struct({
|
||||||
template: z.string(),
|
template: Schema.String,
|
||||||
description: z.string().optional(),
|
description: Schema.optional(Schema.String),
|
||||||
agent: z.string().optional(),
|
agent: Schema.optional(Schema.String),
|
||||||
model: ConfigModelID.optional(),
|
model: Schema.optional(ConfigModelID),
|
||||||
subtask: z.boolean().optional(),
|
subtask: Schema.optional(Schema.Boolean),
|
||||||
})
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|
||||||
export async function load(dir: string) {
|
export async function load(dir: string) {
|
||||||
const result: Record<string, Info> = {}
|
const result: Record<string, Info> = {}
|
||||||
@@ -49,7 +51,7 @@ export async function load(dir: string) {
|
|||||||
...md.data,
|
...md.data,
|
||||||
template: md.content.trim(),
|
template: md.content.trim(),
|
||||||
}
|
}
|
||||||
const parsed = Info.safeParse(config)
|
const parsed = Info.zod.safeParse(config)
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
result[config.name] = parsed.data
|
result[config.name] = parsed.data
|
||||||
continue
|
continue
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user