Compare commits

..
Author SHA1 Message Date
Aaron Iker 130f1273f6 Merge branch 'dev' into black-page-transitions-design-updates 2026-01-14 16:51:44 +01:00
Aaron Iker c5d3533a51 feat: style adjustments, icons updated 2026-01-14 16:46:13 +01:00
Github Action b5e9f96660 Update aarch64-darwin hash 2026-01-14 15:39:39 +00:00
Mani SundararajanandGitHub ad17e8d1f0 feat: add choco and scoop to opencode upgrade methods (#8439) 2026-01-14 09:39:01 -06:00
Ryan VogelandGitHub b75d4d1c5e docs: update screenshot images (#8479) 2026-01-14 09:36:10 -06:00
Github Action cc67bc005d Update Nix flake.lock and x86_64-linux hash 2026-01-14 15:35:07 +00:00
Vladimir GlafirovandGitHub 0ce849c3d5 chore: update gitlab-ai-provider to 3.1.1 and remove unused parameter (#8424) 2026-01-14 09:34:02 -06:00
Aaron Iker 4ba1fe3d9a feat: small adjustments 2026-01-14 16:32:39 +01:00
Aaron Iker 6b63ea98d8 Merge branch 'dev' into black-page-transitions-design-updates 2026-01-14 16:23:00 +01:00
Aaron Iker 2cb1c4cd6d fix: remove unused file 2026-01-14 16:04:57 +01:00
6e13e2f74e fix(session): remove typo'd duplicate path import (#8408) (#8412)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-01-14 09:04:03 -06:00
Aaron Iker bffd832258 feat: small style adjustments 2026-01-14 16:03:26 +01:00
GitHub Action 9fd61aef6e chore: generate 2026-01-14 15:01:42 +00:00
Ryan VogelandGitHub bb3926bf45 fix(homepage): Update FAQ to include desktop and web links (#8453) 2026-01-14 09:01:02 -06:00
Kit LangtonandGitHub b2b123a392 feat(tui): improve question prompt UX (#8339) 2026-01-14 10:00:29 -05:00
Aaron Iker 56758780de feat: updated design, transitions 2026-01-14 15:50:54 +01:00
Aaron Iker 95266fcb6b feat: small improvements 2026-01-14 15:50:35 +01:00
opencode 09ff3b9bb9 release: v1.1.20
Update Nix Hashes / update-linux (push) Has been cancelled
Update Nix Hashes / update-macos (push) Has been cancelled
2026-01-14 13:41:08 +00:00
GitHub Action 2256362ba2 chore: generate 2026-01-14 13:36:44 +00:00
Shane BishopandGitHub 077ca4454f fix(desktop): "load more" button behavior in desktop sidebar (#8430) 2026-01-14 07:36:08 -06:00
Andrew JazbecandGitHub 05cbb11709 fix(ui): layout-bottom icons (#8330) 2026-01-14 07:25:09 -06:00
Dax Raad fcc561ebb7 fix plan mode when not in git worktree 2026-01-14 08:21:26 -05:00
FilipandGitHub ee6ca104e5 fix(app): file listing (#8309) 2026-01-14 07:09:36 -06:00
GitHub Action 4347a77d89 ignore: update download stats 2026-01-14 2026-01-14 12:05:15 +00:00
GitHub Action 76b10d85ee chore: generate 2026-01-14 07:37:11 +00:00
Goni ZahavyandGitHub 45a770cdb1 fix(opencode): fix docker image after sst rename in tips (#8376) 2026-01-14 01:36:36 -06:00
Akshar PatelandGitHub a57c8669b6 feat: show connected providers in /connect dialog (#8351) 2026-01-14 01:35:59 -06:00
f9fcdead55 fix(session): skip duplicate system prompt for Codex OAuth sessions (#8357)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 23:44:39 -06:00
Github Action ff669d4414 Update aarch64-darwin hash 2026-01-14 00:59:52 +00:00
Github Action 9b2d595cfc Update Nix flake.lock and x86_64-linux hash 2026-01-14 00:54:57 +00:00
GitHub Action 3839d70a94 chore: generate 2026-01-14 00:54:06 +00:00
Frank 6fe265e7d8 Merge branch 'zen-black' into dev 2026-01-13 19:51:48 -05:00
GitHub ActionandFrank 2aed4d263b chore: generate 2026-01-13 19:51:02 -05:00
Felix SanchezandFrank e2ac588c84 fix: deduplicate file refs in sent prompts (#8303) 2026-01-13 19:51:02 -05:00
Daniel SauerandFrank 8917dfdf5e fix(tui): track all timeouts in Footer to prevent memory leak (#8255) 2026-01-13 19:51:02 -05:00
Daniel M BrasilandFrank 86900d71f5 fix: add missing metadata() and ask() defintions to ToolContext type (#8269) 2026-01-13 19:51:02 -05:00
⌞L⌝andFrank adcc661798 docs: add 302ai provider (#8142) 2026-01-13 19:51:01 -05:00
Eduard VoiculescuandFrank f4a28b2659 docs: Update plan mode restrictions (#8290) 2026-01-13 19:51:01 -05:00
GitHub ActionandFrank a160a35d0c chore: generate 2026-01-13 19:51:01 -05:00
LeonidasandFrank 90eaf9b3fc fix(TUI): make tui work when OPENCODE_SERVER_PASSWORD is set (#8179) 2026-01-13 19:51:01 -05:00
opencodeandFrank 16d516dbdb release: v1.1.19 2026-01-13 19:51:01 -05:00
Dax RaadandFrank 0026bc5815 do not allow agent to ask custom-less questions 2026-01-13 19:51:01 -05:00
Aiden ClineandFrank bcdaf7e779 tweak: prompt for explore agent better 2026-01-13 19:51:01 -05:00
GitHub ActionandFrank 874e22a045 chore: generate 2026-01-13 19:51:01 -05:00
Vladimir GlafirovandFrank 905226c01e fix: Add Plugin Mocks to Provider Tests (#8276) 2026-01-13 19:51:01 -05:00
AlanandFrank 73adf7e86f fix: update User-Agent string to latest Chrome version in webfetch (#8284) 2026-01-13 19:51:01 -05:00
Dax RaadandFrank 4c37e17ac2 remove plan 2026-01-13 19:51:01 -05:00
Dax RaadandFrank cd6e07355b test: fix plan agent test path from .opencode/plan/* to .opencode/plans/* 2026-01-13 19:51:01 -05:00
GitHub ActionandFrank 29703aee9a chore: generate 2026-01-13 19:51:01 -05:00
DaxandFrank 3997d3f2d7 feat: add plan mode with enter/exit tools (#8281) 2026-01-13 19:51:01 -05:00
Joe HarrisonandFrank 1fccb3bda4 fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) 2026-01-13 19:51:01 -05:00
Aiden ClineandFrank 16b2bfa8ef add family to gpt 5.2 codex in codex plugin 2026-01-13 19:51:01 -05:00
Aiden ClineandFrank 4eb6b57503 tweak: external dir permission rendering in tui 2026-01-13 19:51:01 -05:00
Aiden ClineandFrank 7599396162 tweak: ensure external dir and bash tool invocations render workdir details 2026-01-13 19:51:01 -05:00
Github ActionandFrank d99d1315ee Update aarch64-darwin hash 2026-01-13 19:51:01 -05:00
Github ActionandFrank d831432f93 Update Nix flake.lock and x86_64-linux hash 2026-01-13 19:51:01 -05:00
Dillon MulroyandFrank 0ddf8e6c6e fix(cli): mcp auth duplicate radio button icon (#8273) 2026-01-13 19:50:49 -05:00
a520c4ff98 feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-13 19:50:49 -05:00
Zeke SikelianosandFrank a184714f67 docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) 2026-01-13 19:50:49 -05:00
Daniel SauerandFrank 9b76337236 fix(state): delete key from recordsByKey on instance disposal (#8252) 2026-01-13 19:50:49 -05:00
Daniel SauerandFrank 4dc72669e5 fix(mcp): close existing client before reassignment to prevent leaks (#8253) 2026-01-13 19:50:49 -05:00
Daniel PolitoandFrank dfa59dd21d feat(desktop): Ask Question Tool Support (#8232) 2026-01-13 19:50:49 -05:00
GitHub ActionandFrank f642a6c5b9 chore: generate 2026-01-13 19:50:49 -05:00
cmdr-charaandFrank e37104cb10 feat: add Undertale and Deltarune built-in themes (#8240) 2026-01-13 19:50:49 -05:00
Daniel PolitoandFrank dc654c93d2 fix(desktop): Revert provider icon on select model dialog (#8245) 2026-01-13 19:50:49 -05:00
opencodeandFrank c67b0a9ba4 release: v1.1.18 2026-01-13 19:50:49 -05:00
LeonidasandFrank 5b699a0d9b fix(github): add persist-credentials: false to workflow templates (#8202) 2026-01-13 19:50:49 -05:00
Brendan AllanandFrank bc557e828d console: reduce desktop download cache ttl to 5 minutes 2026-01-13 19:50:49 -05:00
GitHub ActionandFrank fcaa041ef9 chore: generate 2026-01-13 19:50:49 -05:00
Daniel PolitoandFrank 3c9d80d75f feat(desktop): Adding Provider Icons (#8215) 2026-01-13 19:50:49 -05:00
usvimalandFrank a761f66a16 fix(desktop): correct health check endpoint URL to /global/health (#8231) 2026-01-13 19:50:49 -05:00
GitHub ActionandFrank 15e80fca69 chore: generate 2026-01-13 19:50:49 -05:00
Dax RaadandFrank 43680534df add fullscreen view to permission prompt 2026-01-13 19:50:48 -05:00
opencodeandFrank aa522aad62 release: v1.1.17 2026-01-13 19:50:48 -05:00
Frank 82319bbd83 wip: black 2026-01-13 19:46:14 -05:00
Frank 45fa4eda15 wip: black 2026-01-13 19:15:14 -05:00
GitHub Action f242541ef3 chore: generate 2026-01-14 00:04:24 +00:00
Felix SanchezandGitHub 562f067131 fix: deduplicate file refs in sent prompts (#8303) 2026-01-13 18:03:45 -06:00
Daniel SauerandGitHub 1ff46c75fa fix(tui): track all timeouts in Footer to prevent memory leak (#8255) 2026-01-13 18:03:34 -06:00
Daniel M BrasilandGitHub 73d5cacc06 fix: add missing metadata() and ask() defintions to ToolContext type (#8269) 2026-01-13 17:31:18 -06:00
⌞L⌝andGitHub b8828f2609 docs: add 302ai provider (#8142) 2026-01-13 17:00:23 -06:00
Eduard VoiculescuandGitHub 2f7b2cf603 docs: Update plan mode restrictions (#8290) 2026-01-13 16:52:02 -06:00
Frank eaf18d9915 wip: black 2026-01-13 17:51:21 -05:00
GitHub Action 7aa7dd3690 chore: generate 2026-01-13 22:50:56 +00:00
LeonidasandGitHub bee4b6801e fix(TUI): make tui work when OPENCODE_SERVER_PASSWORD is set (#8179) 2026-01-13 16:50:19 -06:00
opencode 3565d8e44d release: v1.1.19
Update Nix Hashes / update-linux (push) Has been cancelled
Update Nix Hashes / update-macos (push) Has been cancelled
2026-01-13 22:27:16 +00:00
Dax Raad 0187b6bb72 do not allow agent to ask custom-less questions 2026-01-13 17:14:12 -05:00
Aiden Cline 0eb898abcf tweak: prompt for explore agent better 2026-01-13 15:35:52 -06:00
GitHub Action 5a309c2dbf chore: generate 2026-01-13 21:24:19 +00:00
Vladimir GlafirovandGitHub 452f11ff77 fix: Add Plugin Mocks to Provider Tests (#8276) 2026-01-13 15:23:41 -06:00
AlanandGitHub 774c24e76e fix: update User-Agent string to latest Chrome version in webfetch (#8284) 2026-01-13 15:23:08 -06:00
Dax Raad ec4a44087b remove plan 2026-01-13 16:20:05 -05:00
Dax Raad 501347cda5 test: fix plan agent test path from .opencode/plan/* to .opencode/plans/* 2026-01-13 16:19:14 -05:00
GitHub Action 3f3816c0f2 chore: generate 2026-01-13 20:56:28 +00:00
DaxandGitHub 0a3c72d678 feat: add plan mode with enter/exit tools (#8281) 2026-01-13 15:55:48 -05:00
Joe HarrisonandGitHub 66b7a4991e fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) 2026-01-13 14:06:38 -06:00
Aiden Cline 1550ae47c0 add family to gpt 5.2 codex in codex plugin 2026-01-13 13:57:34 -06:00
Aiden Cline 33ba064c40 tweak: external dir permission rendering in tui 2026-01-13 13:52:16 -06:00
Aiden Cline 96ae5925c3 tweak: ensure external dir and bash tool invocations render workdir details 2026-01-13 13:52:15 -06:00
Github Action 3a750b0809 Update aarch64-darwin hash 2026-01-13 19:29:19 +00:00
Github Action 1258f7aeea Update Nix flake.lock and x86_64-linux hash 2026-01-13 19:22:49 +00:00
Dillon MulroyandGitHub 797a56873d fix(cli): mcp auth duplicate radio button icon (#8273) 2026-01-13 13:22:26 -06:00
05867f9318 feat: Add GitLab Duo Agentic Chat Provider Support (#7333)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-13 13:21:39 -06:00
Zeke SikelianosandGitHub 5947fe72e4 docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) 2026-01-13 12:58:09 -06:00
Github Action f3d4dd5099 Update aarch64-darwin hash 2026-01-13 18:43:58 +00:00
Daniel SauerandGitHub b68a4a8838 fix(state): delete key from recordsByKey on instance disposal (#8252) 2026-01-13 12:43:16 -06:00
Github Action b7a1d8f2f5 Update Nix flake.lock and x86_64-linux hash 2026-01-13 18:39:01 +00:00
Daniel SauerandGitHub 80e1173ef7 fix(mcp): close existing client before reassignment to prevent leaks (#8253) 2026-01-13 12:38:34 -06:00
Frank 8ae10f1c94 sync 2026-01-13 13:37:48 -05:00
Frank f24251f89e sync 2026-01-13 13:36:37 -05:00
Daniel PolitoandGitHub 3600bd27f4 feat(desktop): Ask Question Tool Support (#8232) 2026-01-13 12:28:08 -06:00
GitHub Action 92089bb295 chore: generate 2026-01-13 18:27:28 +00:00
cmdr-charaandGitHub a70932f742 feat: add Undertale and Deltarune built-in themes (#8240) 2026-01-13 12:26:45 -06:00
Daniel PolitoandGitHub 217cf24c3c fix(desktop): Revert provider icon on select model dialog (#8245) 2026-01-13 12:26:21 -06:00
106 changed files with 7196 additions and 922 deletions
+1
View File
@@ -200,3 +200,4 @@
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
+58 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -84,10 +84,12 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:",
},
@@ -99,7 +101,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +128,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +152,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +176,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -203,7 +205,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -232,7 +234,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -248,7 +250,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.18",
"version": "1.1.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,6 +278,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -351,7 +354,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -371,7 +374,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.18",
"version": "1.1.20",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -382,7 +385,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -395,7 +398,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -435,7 +438,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"zod": "catalog:",
},
@@ -446,7 +449,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -586,6 +589,10 @@
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
@@ -906,6 +913,10 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
@@ -1600,6 +1611,8 @@
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
@@ -1652,6 +1665,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
@@ -2318,6 +2333,10 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -2540,6 +2559,10 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
"graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
@@ -2768,6 +2791,8 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -2800,6 +2825,8 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -3076,6 +3103,8 @@
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
"nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
@@ -3518,6 +3547,10 @@
"smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -3528,6 +3561,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -3682,6 +3717,8 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -3874,6 +3911,8 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -4024,6 +4063,8 @@
"@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -4266,6 +4307,8 @@
"editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768178648,
"narHash": "sha256-kz/F6mhESPvU1diB7tOM3nLcBfQe7GU7GQCymRlTi/s=",
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3fbab70c6e69c87ea2b6e48aa6629da2aa6a23b0",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"type": "github"
},
"original": {
+2
View File
@@ -122,6 +122,7 @@ const ZEN_MODELS = [
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
@@ -177,6 +178,7 @@ new sst.cloudflare.x.SolidStart("Console", {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
VITE_AUTH_URL: auth.url.apply((url) => url!),
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
},
transform: {
server: {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=",
"aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ="
"x86_64-linux": "sha256-1l4twtOi/7YYy1KFJME1XazAgTETAfbxB3EOv2qQeVs=",
"aarch64-darwin": "sha256-jdZI3BA/v35er4xgWkI2rHo54D1TDNVhMX83b5BcIvk="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.18",
"version": "1.1.20",
"description": "",
"type": "module",
"exports": {
@@ -7,8 +7,6 @@ import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
@@ -37,12 +35,6 @@ const ModelList: Component<{
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
groupHeader={(group) => (
<div class="flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={group.items[0].provider.id as IconName} />
<span>{group.category}</span>
</div>
)}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
@@ -60,8 +52,7 @@ const ModelList: Component<{
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3 pl-1 text-13-regular">
<ProviderIcon data-slot="list-item-extra-icon" id={i.provider.id as IconName} />
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
+15 -5
View File
@@ -364,6 +364,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null)
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
const agentList = createMemo(() =>
@@ -881,6 +887,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
// Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input
// and should always insert a newline regardless of composition state
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && isImeComposing(event)) {
return
}
@@ -944,11 +958,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
+84 -3
View File
@@ -16,6 +16,7 @@ import {
type LspStatus,
type VcsInfo,
type PermissionRequest,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -37,6 +38,7 @@ type State = {
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
@@ -49,6 +51,9 @@ type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
@@ -94,10 +99,12 @@ function createGlobalSync() {
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: undefined,
@@ -112,8 +119,10 @@ function createGlobalSync() {
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
globalSDK.client.session
.list({ directory })
const limit = store.limit
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
@@ -123,10 +132,12 @@ function createGlobalSync() {
.sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
if (i < limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {
@@ -208,6 +219,38 @@ function createGlobalSync() {
}
})
}),
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const question of x.data ?? []) {
if (!question?.id || !question.sessionID) continue
const existing = grouped[question.sessionID]
if (existing) {
existing.push(question)
continue
}
grouped[question.sessionID] = [question]
}
batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
@@ -396,6 +439,44 @@ function createGlobalSync() {
)
break
}
case "question.asked": {
const sessionID = event.properties.sessionID
const questions = store.question[sessionID]
if (!questions) {
setStore("question", sessionID, [event.properties])
break
}
const result = Binary.search(questions, event.properties.id, (q) => q.id)
if (result.found) {
setStore("question", sessionID, result.index, reconcile(event.properties))
break
}
setStore(
"question",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const questions = store.question[event.properties.sessionID]
if (!questions) break
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
if (!result.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
@@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
export default function Layout(props: ParentProps) {
const params = useParams()
@@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
sdk.client.question.reply(input)
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
@@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) {
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
>
<LocalProvider>{props.children}</LocalProvider>
+1 -1
View File
@@ -944,7 +944,7 @@ export default function Layout(props: ParentProps) {
.toSorted(sortSessions),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const hasMoreSessions = createMemo(() => store.sessionTotal > store.session.length)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
+4 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
@@ -23,10 +23,12 @@
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:"
},
@@ -5,6 +5,7 @@ import { useAuthSession } from "~/context/auth"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
try {
const code = url.searchParams.get("code")
if (!code) throw new Error("No code found")
@@ -27,7 +28,7 @@ export async function GET(input: APIEvent) {
current: id,
}
})
return redirect("/auth")
return redirect(url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", ""))
} catch (e: any) {
return new Response(
JSON.stringify({
@@ -2,6 +2,9 @@ import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
export async function GET(input: APIEvent) {
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
const url = new URL(input.request.url)
const cont = url.searchParams.get("continue") ?? ""
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
return Response.redirect(result.url, 302)
}
+806
View File
@@ -0,0 +1,806 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero"] {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
margin-top: 40px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 60px;
}
h1 {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%;
margin: 0;
@media (min-width: 768px) {
font-size: 22px;
}
}
p {
color: rgba(255, 255, 255, 0.59);
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%;
margin: 0;
@media (min-width: 768px) {
font-size: 22px;
}
}
}
[data-slot="hero-black"] {
margin-top: 40px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 60px;
}
svg {
--hero-black-fill-from: hsl(0 0% 100%);
--hero-black-fill-to: hsl(0 0% 100% / 0%);
--hero-black-stroke-from: hsl(0 0% 100% / 60%);
--hero-black-stroke-to: hsl(0 0% 100% / 0%);
width: 100%;
max-width: 590px;
height: auto;
filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1));
mask-image: linear-gradient(to bottom, black, transparent);
stroke-width: 1.5;
[data-slot="black-fill"] {
fill: url(#hero-black-fill-gradient);
}
[data-slot="black-stroke"] {
fill: url(#hero-black-stroke-gradient);
}
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
text-align: center;
margin-top: -32px;
width: 100%;
@media (min-width: 768px) {
margin-top: -16px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%;
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: var(--font-mono);
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: var(--font-mono);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="pricing"] {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 680px;
padding: 0 20px;
box-sizing: border-box;
}
[data-slot="pricing-card"] {
display: flex;
flex-direction: column;
align-items: flex-start;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 5px;
text-decoration: none;
background: #000;
text-align: left;
overflow: hidden;
width: 100%;
transition: border-color 200ms ease;
&:hover:not([data-selected="true"]) {
border-color: rgba(255, 255, 255, 0.35);
}
[data-slot="card-trigger"] {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 24px;
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: padding 200ms ease;
&:disabled {
cursor: default;
}
}
&[data-selected="true"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="terms"] {
animation: reveal 500ms cubic-bezier(0.25, 0, 0.5, 1) forwards;
}
[data-slot="actions"] {
[data-slot="continue"] {
animation-delay: 200ms;
}
}
}
&[data-collapsed="true"] {
[data-slot="card-trigger"] {
padding: 20px 24px;
}
[data-slot="plan-header"] {
flex-direction: row;
}
[data-slot="amount"] {
font-size: 20px;
}
}
&[data-selected="false"][data-collapsed="false"] {
[data-slot="amount"] {
font-size: 22px;
}
[data-slot="period"],
[data-slot="multiplier"] {
font-size: 14px;
}
}
[data-slot="plan-header"] {
display: flex;
flex-direction: column;
width: 100%;
gap: 12px;
transition: gap 200ms ease;
}
[data-slot="plan-icon"] {
color: rgba(255, 255, 255, 0.59);
flex-shrink: 0;
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
line-height: 24px;
margin: 0;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
}
[data-slot="content"] {
width: 100%;
}
[data-slot="period"],
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="billing"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
&::before {
content: "·";
margin-right: 8px;
}
}
[data-slot="terms"] {
list-style: none;
padding: 0 24px 24px 24px;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
width: 100%;
opacity: 0;
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
mask-repeat: no-repeat;
mask-size: 100% 200%;
mask-position: 0% 320%;
}
[data-slot="terms"] li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
line-height: 1.2;
padding-left: 16px;
position: relative;
white-space: nowrap;
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
}
}
[data-slot="actions"] {
display: flex;
gap: 16px;
margin-top: 8px;
padding: 0 24px 24px 24px;
box-sizing: border-box;
width: 100%;
}
[data-slot="actions"] button,
[data-slot="actions"] a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
transition-property: background-color, border-color;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
[data-slot="cancel"] {
border: 1px solid var(--border-base, rgba(255, 255, 255, 0.17));
background: var(--surface-raised-base, rgba(255, 255, 255, 0.06));
background-clip: border-box;
color: rgba(255, 255, 255, 0.92);
&:hover {
background: var(--surface-raised-base, rgba(255, 255, 255, 0.08));
border-color: rgba(255, 255, 255, 0.25);
}
}
[data-slot="continue"] {
background: rgb(255, 255, 255);
color: rgb(0, 0, 0);
&:hover {
background: rgb(255, 255, 255, 0.9);
}
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
}
/* Subscribe page styles */
[data-slot="subscribe-form"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
margin-top: -18px;
width: 100%;
max-width: 540px;
padding: 0 20px;
@media (min-width: 768px) {
margin-top: 40px;
padding: 0;
}
[data-slot="form-card"] {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
[data-slot="plan-header"] {
display: flex;
flex-direction: column;
gap: 8px;
}
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-weight: 400;
margin-bottom: 8px;
}
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 13px;
&::before {
content: "·";
margin: 0 8px;
}
}
[data-slot="divider"] {
height: 1px;
background: rgba(255, 255, 255, 0.17);
}
[data-slot="section-title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-weight: 400;
}
[data-slot="tax-id-section"] {
display: flex;
flex-direction: column;
gap: 8px;
[data-slot="label"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="input"] {
width: 100%;
height: 44px;
padding: 0 12px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
color: #ffffff;
font-family: var(--font-mono);
font-size: 14px;
outline: none;
transition: border-color 0.15s ease;
&::placeholder {
color: rgba(255, 255, 255, 0.39);
}
&:focus {
border-color: rgba(255, 255, 255, 0.35);
}
}
}
[data-slot="checkout-form"] {
display: flex;
flex-direction: column;
gap: 20px;
}
[data-slot="error"] {
color: #ff6b6b;
font-size: 14px;
}
[data-slot="submit-button"] {
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.92);
border: none;
border-radius: 4px;
color: #000;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover:not(:disabled) {
background: #e0e0e0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
[data-slot="charge-notice"] {
color: #d4a500;
font-size: 14px;
text-align: center;
}
[data-slot="success"] {
display: flex;
flex-direction: column;
gap: 24px;
[data-slot="title"] {
color: rgba(255, 255, 255, 0.92);
font-size: 18px;
font-weight: 400;
margin: 0;
}
[data-slot="details"] {
display: flex;
flex-direction: column;
gap: 16px;
> div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
dt {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
font-weight: 400;
}
dd {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 400;
margin: 0;
text-align: right;
}
}
[data-slot="charge-notice"] {
color: #d4a500;
font-size: 14px;
text-align: left;
}
}
[data-slot="loading"] {
display: flex;
justify-content: center;
padding: 40px 0;
p {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
[data-slot="workspace-picker"] {
[data-slot="workspace-list"] {
width: 100%;
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
outline: none;
overflow-y: auto;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="workspace-item"] {
width: 100%;
display: flex;
padding: 8px 12px;
align-items: center;
gap: 8px;
align-self: stretch;
cursor: pointer;
[data-slot="selected-icon"] {
visibility: hidden;
color: rgba(255, 255, 255, 0.39);
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
span:last-child {
color: rgba(255, 255, 255, 0.92);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 160%;
}
&:hover,
&[data-active="true"] {
background: #161616;
[data-slot="selected-icon"] {
visibility: visible;
}
}
}
}
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: var(--font-mono);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: var(--font-mono);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
::view-transition-group(*) {
animation-duration: 200ms;
animation-timing-function: cubic-bezier(0.25, 0, 0.5, 1);
}
@keyframes reveal {
100% {
mask-position: 0% 0%;
opacity: 1;
}
}
+152
View File
@@ -0,0 +1,152 @@
import { A, createAsync, RouteSectionProps } from "@solidjs/router"
import { createMemo } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
import "./black.css"
export default function BlackLayout(props: RouteSectionProps) {
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
: config.github.starsFormatted.compact,
)
return (
<div data-page="black">
<div data-component="header-gradient" />
<header data-component="header">
<div data-component="header-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="179" height="32" viewBox="0 0 179 32" fill="none">
<title>opencode</title>
<g clip-path="url(#clip0_3654_210259)">
<mask
id="mask0_3654_210259"
style="mask-type:luminance"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="179"
height="32"
>
<path d="M178.286 0H0V32H178.286V0Z" fill="white" />
</mask>
<g mask="url(#mask0_3654_210259)">
<path d="M13.7132 22.8577H4.57031V13.7148H13.7132V22.8577Z" fill="#444444" />
<path
d="M13.7143 9.14174H4.57143V22.856H13.7143V9.14174ZM18.2857 27.4275H0V4.57031H18.2857V27.4275Z"
fill="#CDCDCD"
/>
<path d="M36.5725 22.8577H27.4297V13.7148H36.5725V22.8577Z" fill="#444444" />
<path
d="M27.4308 22.856H36.5737V9.14174H27.4308V22.856ZM41.1451 27.4275H27.4308V31.9989H22.8594V4.57031H41.1451V27.4275Z"
fill="#CDCDCD"
/>
<path d="M64.0033 18.2852V22.8566H50.2891V18.2852H64.0033Z" fill="#444444" />
<path
d="M63.9967 18.2846H50.2824V22.856H63.9967V27.4275H45.7109V4.57031H63.9967V18.2846ZM50.2824 13.7132H59.4252V9.14174H50.2824V13.7132Z"
fill="#CDCDCD"
/>
<path d="M82.2835 27.4291H73.1406V13.7148H82.2835V27.4291Z" fill="#444444" />
<path
d="M82.2846 9.14174H73.1417V27.4275H68.5703V4.57031H82.2846V9.14174ZM86.856 27.4275H82.2846V9.14174H86.856V27.4275Z"
fill="#CDCDCD"
/>
<path d="M109.714 22.8577H96V13.7148H109.714V22.8577Z" fill="#444444" />
<path
d="M109.715 9.14174H96.0011V22.856H109.715V27.4275H91.4297V4.57031H109.715V9.14174Z"
fill="white"
/>
<path d="M128.002 22.8577H118.859V13.7148H128.002V22.8577Z" fill="#444444" />
<path
d="M128.003 9.14174H118.86V22.856H128.003V9.14174ZM132.575 27.4275H114.289V4.57031H132.575V27.4275Z"
fill="white"
/>
<path d="M150.854 22.8577H141.711V13.7148H150.854V22.8577Z" fill="#444444" />
<path
d="M150.855 9.14286H141.712V22.8571H150.855V9.14286ZM155.426 27.4286H137.141V4.57143H150.855V0H155.426V27.4286Z"
fill="white"
/>
<path d="M178.285 18.2852V22.8566H164.57V18.2852H178.285Z" fill="#444444" />
<path
d="M164.571 9.14174V13.7132H173.714V9.14174H164.571ZM178.286 18.2846H164.571V22.856H178.286V27.4275H160V4.57031H178.286V18.2846Z"
fill="white"
/>
</g>
</g>
<defs>
<clipPath id="clip0_3654_210259">
<rect width="178.286" height="32" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</header>
<main data-component="content">
<div data-slot="hero">
<h1>Access all the world's best coding models</h1>
<p>Including Claude, GPT, Gemini and more</p>
</div>
<div data-slot="hero-black">
<svg width="591" height="90" viewBox="0 0 591 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M425.56 0.75C429.464 0.750017 432.877 1.27807 435.78 2.35645C438.656 3.42455 441.138 4.86975 443.215 6.69727C445.268 8.50382 446.995 10.5587 448.394 12.8604C449.77 15.0464 450.986 17.2741 452.04 19.5439L452.357 20.2275L451.672 20.542L443.032 24.502L442.311 24.833L442.021 24.0938C441.315 22.2906 440.494 20.6079 439.557 19.0459L439.552 19.0391L439.548 19.0322C438.626 17.419 437.517 16.0443 436.223 14.9023L436.206 14.8867L436.189 14.8701C434.989 13.6697 433.518 12.7239 431.766 12.0381L431.755 12.0342V12.0332C430.111 11.3607 428.053 11.0098 425.56 11.0098C419.142 11.0098 414.433 13.4271 411.308 18.2295C408.212 23.109 406.629 29.6717 406.629 37.9805V51.6602C406.629 59.9731 408.214 66.5377 411.312 71.418C414.438 76.2157 419.145 78.6299 425.56 78.6299C428.054 78.6299 430.111 78.2782 431.756 77.6055L431.766 77.6016L432.413 77.333C433.893 76.6811 435.154 75.8593 436.206 74.873C437.512 73.644 438.625 72.2626 439.548 70.7275C440.489 69.0801 441.314 67.3534 442.021 65.5469L442.311 64.8076L443.032 65.1387L451.672 69.0986L452.348 69.4082L452.044 70.0869C450.99 72.439 449.773 74.7099 448.395 76.8994C446.995 79.1229 445.266 81.1379 443.215 82.9434C441.138 84.7708 438.656 86.2151 435.78 87.2832C432.877 88.3616 429.464 88.8896 425.56 88.8896C415.111 88.8896 407.219 85.0777 402.019 77.4004L402.016 77.3965C396.939 69.7818 394.449 58.891 394.449 44.8203C394.449 30.7495 396.939 19.8589 402.016 12.2441L402.019 12.2393C407.219 4.56202 415.111 0.75 425.56 0.75ZM29.9404 2.19043C37.2789 2.19051 43.125 4.19131 47.3799 8.2793C51.6307 12.3635 53.7305 17.8115 53.7305 24.54C53.7305 29.6953 52.4605 33.8451 49.835 36.8994L49.8359 36.9004C47.7064 39.4558 45.0331 41.367 41.835 42.6445C45.893 43.8751 49.3115 45.9006 52.0703 48.7295C55.2954 51.9546 56.8496 56.6143 56.8496 62.5801C56.8496 66.0251 56.2751 69.2753 55.1211 72.3252C53.9689 75.3702 52.3185 78.014 50.1689 80.249L50.1699 80.25C48.0996 82.4858 45.6172 84.2628 42.7314 85.582L42.7227 85.5859C39.9002 86.8312 36.8362 87.4502 33.54 87.4502H0.75V2.19043H29.9404ZM148.123 2.19043V77.1904H187.843V87.4502H136.543V2.19043H148.123ZM298.121 2.19043L298.283 2.71973L323.963 86.4805L324.261 87.4502H312.006L311.848 86.9131L304.927 63.5703H276.646L269.726 86.9131L269.566 87.4502H257.552L257.85 86.4805L283.529 2.71973L283.691 2.19043H298.121ZM539.782 2.19043V44.9209L549.845 32.2344L549.851 32.2275L549.855 32.2207L574.575 2.46094L574.801 2.19043H588.874L587.849 3.41992L558.795 38.2832L588.749 86.3027L589.464 87.4502H575.934L575.714 87.0938L550.937 46.9316L539.782 60.0947V87.4502H528.202V2.19043H539.782ZM12.3301 77.1904H30.54C35.0749 77.1904 38.5307 76.1729 40.9961 74.2305C43.4059 72.3317 44.6699 69.3811 44.6699 65.2197V60.2998C44.6699 56.2239 43.4093 53.3106 40.9961 51.4092L40.9854 51.4004C38.5207 49.3838 35.0691 48.3301 30.54 48.3301H12.3301V77.1904ZM279.485 53.3096H302.087L290.786 14.4482L279.485 53.3096ZM12.3301 38.5498H28.8604C33 38.5498 36.1378 37.6505 38.3633 35.9443C40.5339 34.2015 41.6698 31.5679 41.6699 27.9004V23.2197C41.6699 19.5455 40.5299 16.9088 38.3516 15.166C36.1272 13.3865 32.9938 12.4502 28.8604 12.4502H12.3301V38.5498Z"
fill="url(#hero-black-fill-gradient)"
fill-opacity="0.1"
stroke="url(#hero-black-stroke-gradient)"
stroke-width="1.5"
/>
<defs>
<linearGradient
id="hero-black-fill-gradient"
x1="290.82"
y1="1.57422"
x2="290.82"
y2="87.0326"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--hero-black-fill-from)" />
<stop offset="1" stop-color="var(--hero-black-fill-to)" />
</linearGradient>
<linearGradient
id="hero-black-stroke-gradient"
x1="290.82"
y1="2.03255"
x2="290.82"
y2="87.0325"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--hero-black-stroke-from)" />
<stop offset="1" stop-color="var(--hero-black-stroke-to)" />
</linearGradient>
</defs>
</svg>
</div>
{props.children}
</main>
<footer data-component="footer">
<div data-slot="footer-content">
<span data-slot="anomaly">
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<a href={config.github.repoUrl} target="_blank">
GitHub <span data-slot="github-stars">[{starCount()}]</span>
</a>
<a href="/docs">Docs</a>
<span>
<A href="/legal/privacy-policy">Privacy</A>
</span>
<span>
<A href="/legal/terms-of-service">Terms</A>
</span>
</div>
<span data-slot="anomaly-alt">
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
</footer>
</div>
)
}
@@ -0,0 +1,62 @@
import { Match, Switch } from "solid-js"
export const plans = [
{ id: "20", multiplier: null },
{ id: "100", multiplier: "6x more usage than Black 20" },
{ id: "200", multiplier: "21x more usage than Black 20" },
] as const
export type PlanID = (typeof plans)[number]["id"]
export type Plan = (typeof plans)[number]
export function PlanIcon(props: { plan: string }) {
return (
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 20 plan</title>
<rect x="0.5" y="0.5" width="23" height="23" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 100 plan</title>
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="0.5" y="14.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="0.5" width="9" height="9" stroke="currentColor" />
<rect x="14.5" y="14.5" width="9" height="9" stroke="currentColor" />
</svg>
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>Black 200 plan</title>
<rect x="0.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="0.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="5.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="10.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="15.5" y="20.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="0.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="5.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="10.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="15.5" width="3" height="3" stroke="currentColor" />
<rect x="20.5" y="20.5" width="3" height="3" stroke="currentColor" />
</svg>
</Match>
</Switch>
)
}
@@ -1,409 +0,0 @@
[data-page="black"] {
background: #000;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
font-family: var(--font-mono);
color: #fff;
[data-component="header-gradient"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 288px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16) 0%, rgba(0, 0, 0, 0) 100%);
}
[data-component="header"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40px;
flex-shrink: 0;
/* [data-component="header-logo"] { */
/* } */
}
[data-component="content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex-grow: 1;
[data-slot="hero-black"] {
margin-top: 110px;
@media (min-width: 768px) {
margin-top: 150px;
}
}
[data-slot="cta"] {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
text-align: center;
margin-top: -18px;
@media (min-width: 768px) {
margin-top: 40px;
}
[data-slot="heading"] {
color: rgba(255, 255, 255, 0.92);
text-align: center;
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 28.8px */
span {
display: inline-block;
}
}
[data-slot="subheading"] {
color: rgba(255, 255, 255, 0.59);
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 160%;
@media (min-width: 768px) {
font-size: 18px;
line-height: 160%;
}
}
[data-slot="button"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
text-decoration: none;
color: #000;
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
background: #e0e0e0;
}
&:active {
transform: scale(0.98);
}
}
[data-slot="back-soon"] {
color: rgba(255, 255, 255, 0.59);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
}
[data-slot="follow-us"] {
display: inline-flex;
height: 40px;
padding: 0 12px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
font-family: "JetBrains Mono Nerd Font";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="pricing"] {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 540px;
padding: 0 20px;
@media (min-width: 768px) {
padding: 0;
}
}
[data-slot="pricing-card"] {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
text-decoration: none;
transition: border-color 0.15s ease;
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
}
[data-slot="selected-plan"] {
display: flex;
flex-direction: column;
gap: 32px;
width: fit-content;
max-width: calc(100% - 40px);
margin: 0 auto;
}
[data-slot="selected-card"] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: fit-content;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
[data-slot="terms"] {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
line-height: 1.5;
padding-left: 16px;
position: relative;
white-space: nowrap;
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
}
}
}
[data-slot="actions"] {
display: flex;
gap: 16px;
margin-top: 8px;
button,
a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
}
[data-slot="cancel"] {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
}
[data-slot="continue"] {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
}
}
[data-component="footer"] {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
gap: 24px;
flex-shrink: 0;
@media (min-width: 768px) {
height: 120px;
}
[data-slot="footer-content"] {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
@media (min-width: 768px) {
gap: 40px;
}
span,
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
[data-slot="github-stars"] {
color: rgba(255, 255, 255, 0.25);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
[data-slot="anomaly"] {
display: none;
@media (min-width: 768px) {
display: block;
}
}
}
[data-slot="anomaly-alt"] {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
margin-bottom: 24px;
a {
color: rgba(255, 255, 255, 0.39);
font-family: "JetBrains Mono Nerd Font";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
text-decoration: none;
}
@media (min-width: 768px) {
display: none;
}
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,450 @@
import { A, createAsync, query, redirect, useParams } from "@solidjs/router"
import { Title } from "@solidjs/meta"
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js"
import { type Stripe, type PaymentMethod, loadStripe } from "@stripe/stripe-js"
import { Elements, PaymentElement, useStripe, useElements, AddressElement } from "solid-stripe"
import { PlanID, plans } from "../common"
import { getActor, useAuthSession } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { createList } from "solid-list"
import { Modal } from "~/component/modal"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<PlanID, (typeof plans)[number]>
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!)
const getWorkspaces = query(async () => {
"use server"
const actor = await getActor()
if (actor.type === "public") throw redirect("/auth/authorize?continue=/black/subscribe")
return withActor(async () => {
return Database.use((tx) =>
tx
.select({
id: WorkspaceTable.id,
name: WorkspaceTable.name,
slug: WorkspaceTable.slug,
billing: {
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
subscriptionID: BillingTable.subscriptionID,
timeSubscriptionBooked: BillingTable.timeSubscriptionBooked,
},
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.innerJoin(BillingTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
.where(
and(
eq(UserTable.accountID, Actor.account()),
isNull(WorkspaceTable.timeDeleted),
isNull(UserTable.timeDeleted),
),
),
)
})
}, "black.subscribe.workspaces")
const createSetupIntent = async (input: { plan: string; workspaceID: string }) => {
"use server"
const { plan, workspaceID } = input
if (!plan || !["20", "100", "200"].includes(plan)) return { error: "Invalid plan" }
if (!workspaceID) return { error: "Workspace ID is required" }
return withActor(async () => {
const session = await useAuthSession()
const account = session.data.account?.[session.data.current ?? ""]
const email = account?.email
const customer = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (customer?.subscriptionID) {
return { error: "This workspace already has a subscription" }
}
let customerID = customer?.customerID
if (!customerID) {
const customer = await Billing.stripe().customers.create({
email,
metadata: {
workspaceID,
},
})
customerID = customer.id
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
customerID,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
}
const intent = await Billing.stripe().setupIntents.create({
customer: customerID,
payment_method_types: ["card"],
metadata: {
workspaceID,
},
})
return { clientSecret: intent.client_secret ?? undefined }
}, workspaceID)
}
const bookSubscription = async (input: {
workspaceID: string
plan: PlanID
paymentMethodID: string
paymentMethodType: string
paymentMethodLast4?: string
}) => {
"use server"
return withActor(
() =>
Database.use((tx) =>
tx
.update(BillingTable)
.set({
paymentMethodID: input.paymentMethodID,
paymentMethodType: input.paymentMethodType,
paymentMethodLast4: input.paymentMethodLast4,
subscriptionPlan: input.plan,
timeSubscriptionBooked: new Date(),
})
.where(eq(BillingTable.workspaceID, input.workspaceID)),
),
input.workspaceID,
)
}
interface SuccessData {
plan: string
paymentMethodType: string
paymentMethodLast4?: string
}
function Failure(props: { message: string }) {
return (
<div data-slot="failure">
<p data-slot="message">Uh oh! {props.message}</p>
</div>
)
}
function Success(props: SuccessData) {
return (
<div data-slot="success">
<p data-slot="title">You're on the OpenCode Black waitlist</p>
<dl data-slot="details">
<div>
<dt>Subscription plan</dt>
<dd>OpenCode Black {props.plan}</dd>
</div>
<div>
<dt>Amount</dt>
<dd>${props.plan} per month</dd>
</div>
<div>
<dt>Payment method</dt>
<dd>
<Show when={props.paymentMethodLast4} fallback={<span>{props.paymentMethodType}</span>}>
<span>
{props.paymentMethodType} - {props.paymentMethodLast4}
</span>
</Show>
</dd>
</div>
<div>
<dt>Date joined</dt>
<dd>{new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</dd>
</div>
</dl>
<p data-slot="charge-notice">Your card will be charged when your subscription is activated</p>
</div>
)
}
function IntentForm(props: { plan: PlanID; workspaceID: string; onSuccess: (data: SuccessData) => void }) {
const stripe = useStripe()
const elements = useElements()
const [error, setError] = createSignal<string | undefined>(undefined)
const [loading, setLoading] = createSignal(false)
const handleSubmit = async (e: Event) => {
e.preventDefault()
if (!stripe() || !elements()) return
setLoading(true)
setError(undefined)
const result = await elements()!.submit()
if (result.error) {
setError(result.error.message ?? "An error occurred")
setLoading(false)
return
}
const { error: confirmError, setupIntent } = await stripe()!.confirmSetup({
elements: elements()!,
confirmParams: {
expand: ["payment_method"],
payment_method_data: {
allow_redisplay: "always",
},
},
redirect: "if_required",
})
if (confirmError) {
setError(confirmError.message ?? "An error occurred")
setLoading(false)
return
}
// TODO
console.log(setupIntent)
if (setupIntent?.status === "succeeded") {
const pm = setupIntent.payment_method as PaymentMethod
await bookSubscription({
workspaceID: props.workspaceID,
plan: props.plan,
paymentMethodID: pm.id,
paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4,
})
props.onSuccess({
plan: props.plan,
paymentMethodType: pm.type,
paymentMethodLast4: pm.card?.last4,
})
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit} data-slot="checkout-form">
<PaymentElement />
<AddressElement options={{ mode: "billing" }} />
<Show when={error()}>
<p data-slot="error">{error()}</p>
</Show>
<button type="submit" disabled={loading() || !stripe() || !elements()} data-slot="submit-button">
{loading() ? "Processing..." : `Subscribe $${props.plan}`}
</button>
<p data-slot="charge-notice">You will only be charged when your subscription is activated</p>
</form>
)
}
export default function BlackSubscribe() {
const workspaces = createAsync(() => getWorkspaces())
const [selectedWorkspace, setSelectedWorkspace] = createSignal<string | undefined>(undefined)
const [success, setSuccess] = createSignal<SuccessData | undefined>(undefined)
const [failure, setFailure] = createSignal<string | undefined>(undefined)
const [clientSecret, setClientSecret] = createSignal<string | undefined>(undefined)
const [stripe, setStripe] = createSignal<Stripe | undefined>(undefined)
const params = useParams()
const planData = plansMap[(params.plan as PlanID) ?? "20"] ?? plansMap["20"]
const plan = planData.id
// Resolve stripe promise once
createEffect(() => {
stripePromise.then((s) => {
if (s) setStripe(s)
})
})
// Auto-select if only one workspace
createEffect(() => {
const ws = workspaces()
if (ws?.length === 1 && !selectedWorkspace()) {
setSelectedWorkspace(ws[0].id)
}
})
// Fetch setup intent when workspace is selected (unless workspace already has payment method)
createEffect(async () => {
const id = selectedWorkspace()
if (!id) return
const ws = workspaces()?.find((w) => w.id === id)
if (ws?.billing?.subscriptionID) {
setFailure("This workspace already has a subscription")
return
}
if (ws?.billing?.paymentMethodID) {
if (!ws?.billing?.timeSubscriptionBooked) {
await bookSubscription({
workspaceID: id,
plan: planData.id,
paymentMethodID: ws.billing.paymentMethodID!,
paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
})
}
setSuccess({
plan: planData.id,
paymentMethodType: ws.billing.paymentMethodType!,
paymentMethodLast4: ws.billing.paymentMethodLast4 ?? undefined,
})
return
}
const result = await createSetupIntent({ plan, workspaceID: id })
if (result.error) {
setFailure(result.error)
} else if ("clientSecret" in result) {
setClientSecret(result.clientSecret)
}
})
// Keyboard navigation for workspace picker
const { active, setActive, onKeyDown } = createList({
items: () => workspaces()?.map((w) => w.id) ?? [],
initialActive: null,
})
const handleSelectWorkspace = (id: string) => {
setSelectedWorkspace(id)
}
let listRef: HTMLUListElement | undefined
// Show workspace picker if multiple workspaces and none selected
const showWorkspacePicker = () => {
const ws = workspaces()
return ws && ws.length > 1 && !selectedWorkspace()
}
return (
<>
<Title>Subscribe to OpenCode Black</Title>
<section data-slot="subscribe-form">
<div data-slot="form-card">
<Switch>
<Match when={success()}>{(data) => <Success {...data()} />}</Match>
<Match when={failure()}>{(data) => <Failure message={data()} />}</Match>
<Match when={true}>
<>
<div data-slot="plan-header">
<p data-slot="title">Subscribe to OpenCode Black</p>
<p data-slot="price">
<span data-slot="amount">${planData.id}</span> <span data-slot="period">per month</span>
<Show when={planData.multiplier}>
<span data-slot="multiplier">{planData.multiplier}</span>
</Show>
</p>
</div>
<div data-slot="divider" />
<p data-slot="section-title">Payment method</p>
<Show
when={clientSecret() && selectedWorkspace() && stripe()}
fallback={
<div data-slot="loading">
<p>{selectedWorkspace() ? "Loading payment form..." : "Select a workspace to continue"}</p>
</div>
}
>
<Elements
stripe={stripe()!}
options={{
clientSecret: clientSecret()!,
appearance: {
theme: "night",
variables: {
colorPrimary: "#ffffff",
colorBackground: "#1a1a1a",
colorText: "#ffffff",
colorTextSecondary: "#999999",
colorDanger: "#ff6b6b",
fontFamily: "JetBrains Mono, monospace",
borderRadius: "4px",
spacingUnit: "4px",
},
rules: {
".Input": {
backgroundColor: "#1a1a1a",
border: "1px solid rgba(255, 255, 255, 0.17)",
color: "#ffffff",
},
".Input:focus": {
borderColor: "rgba(255, 255, 255, 0.35)",
boxShadow: "none",
},
".Label": {
color: "rgba(255, 255, 255, 0.59)",
fontSize: "14px",
marginBottom: "8px",
},
},
},
}}
>
<IntentForm plan={plan} workspaceID={selectedWorkspace()!} onSuccess={setSuccess} />
</Elements>
</Show>
</>
</Match>
</Switch>
</div>
{/* Workspace picker modal */}
<Modal open={showWorkspacePicker() ?? false} onClose={() => {}} title="Select a workspace for this plan">
<div data-slot="workspace-picker">
<ul
ref={listRef}
data-slot="workspace-list"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" && active()) {
handleSelectWorkspace(active()!)
} else {
onKeyDown(e)
}
}}
>
<For each={workspaces()}>
{(workspace) => (
<li
data-slot="workspace-item"
data-active={active() === workspace.id}
onMouseEnter={() => setActive(workspace.id)}
onClick={() => handleSelectWorkspace(workspace.id)}
>
<span data-slot="selected-icon">[*]</span>
<span>{workspace.name || workspace.slug}</span>
</li>
)}
</For>
</ul>
</div>
</Modal>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</section>
</>
)
}
@@ -441,7 +441,8 @@ export default function Download() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your desktop.
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/cli/#web">web</a>!
</Faq>
</li>
<li>
+2 -1
View File
@@ -692,7 +692,8 @@ export default function Home() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Not anymore! OpenCode is now available as an app for your desktop.
Not anymore! OpenCode is now available as an app for your <a href="/download">desktop</a> and{" "}
<a href="/docs/cli/#web">web</a>!
</Faq>
</li>
<li>
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `time_subscription_booked` timestamp(3);
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription_plan` enum('20','100','200');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -358,6 +358,20 @@
"when": 1767931290031,
"tag": "0050_bumpy_mephistopheles",
"breakpoints": true
},
{
"idx": 51,
"version": "5",
"when": 1768341152722,
"tag": "0051_jazzy_green_goblin",
"breakpoints": true
},
{
"idx": 52,
"version": "5",
"when": 1768343920467,
"tag": "0052_aromatic_agent_zero",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.18",
"version": "1.1.20",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,4 +1,4 @@
import { bigint, boolean, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -23,6 +23,8 @@ export const BillingTable = mysqlTable(
timeReloadLockedTill: utc("time_reload_locked_till"),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
timeSubscriptionBooked: utc("time_subscription_booked"),
},
(table) => [
...workspaceIndexes(table),
+4
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.18",
"version": "1.1.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.18",
"version": "1.1.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.18",
"version": "1.1.20",
"private": true,
"type": "module",
"license": "MIT",
+4
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.18"
version = "1.1.20"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.18/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.18/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.18/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.18/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.18/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.20/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.18",
"version": "1.1.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4
View File
@@ -78,6 +78,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_PUBLISHABLE_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.18",
"version": "1.1.20",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -70,6 +70,7 @@
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
+11 -1
View File
@@ -13,6 +13,8 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
export namespace Agent {
export const Info = z
@@ -53,6 +55,8 @@ export namespace Agent {
[Truncate.GLOB]: "allow",
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -71,6 +75,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
@@ -84,9 +89,14 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
".opencode/plans/*.md": "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, "plans/*.md"))]: "allow",
},
}),
user,
+1 -1
View File
@@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string {
case "expired":
return "⚠"
case "not_authenticated":
return ""
return ""
}
}
@@ -26,67 +26,82 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
map((provider) => {
const isConnected = connected().has(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
footer: isConnected ? "Connected" : undefined,
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options
@@ -159,6 +159,26 @@ export function Autocomplete(props: {
})
props.setPrompt((draft) => {
if (part.type === "file") {
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
if (existingIndex !== -1) {
const existing = draft.parts[existingIndex]
if (
part.source?.text &&
existing &&
"source" in existing &&
existing.source &&
"text" in existing.source &&
existing.source.text
) {
existing.source.text.start = extmarkStart
existing.source.text.end = extmarkEnd
existing.source.text.value = virtualText
}
return
}
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
@@ -139,7 +139,7 @@ const TIPS = [
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
@@ -25,24 +25,27 @@ export function Footer() {
})
onMount(() => {
// Track all timeouts to ensure proper cleanup
const timeouts: ReturnType<typeof setTimeout>[] = []
function tick() {
if (connected()) return
if (!store.welcome) {
setStore("welcome", true)
timeout = setTimeout(() => tick(), 5000)
timeouts.push(setTimeout(() => tick(), 5000))
return
}
if (store.welcome) {
setStore("welcome", false)
timeout = setTimeout(() => tick(), 10_000)
timeouts.push(setTimeout(() => tick(), 10_000))
return
}
}
let timeout = setTimeout(() => tick(), 10_000)
timeouts.push(setTimeout(() => tick(), 10_000))
onCleanup(() => {
clearTimeout(timeout)
timeouts.forEach(clearTimeout)
})
})
@@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -195,6 +196,23 @@ export function Session() {
}
})
let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
if (part.state.status !== "completed") return
if (part.id === lastSwitch) return
if (part.tool === "plan_exit") {
local.agent.set("build")
lastSwitch = part.id
} else if (part.tool === "plan_enter") {
local.agent.set("plan")
lastSwitch = part.id
}
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
@@ -1525,6 +1543,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
function Bash(props: ToolProps<typeof BashTool>) {
const { theme } = useTheme()
const sync = useSync()
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
@@ -1534,11 +1553,36 @@ function Bash(props: ToolProps<typeof BashTool>) {
return [...lines().slice(0, 10), "…"].join("\n")
})
const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)
if (absolute === base) return undefined
const home = Global.Path.home
if (!home) return absolute
const match = absolute === home || absolute.startsWith(home + path.sep)
return match ? absolute.replace(home, "~") : absolute
})
const title = createMemo(() => {
const desc = props.input.description ?? "Shell"
const wd = workdirDisplay()
if (!wd) return `# ${desc}`
if (desc.includes(wd)) return `# ${desc}`
return `# ${desc} in ${wd}`
})
return (
<Switch>
<Match when={props.metadata.output !== undefined}>
<BlockTool
title={"# " + (props.input.description ?? "Shell")}
title={title()}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
@@ -1850,10 +1894,10 @@ function Question(props: ToolProps<typeof QuestionTool>) {
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="row" gap={1}>
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
@@ -13,15 +13,26 @@ import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
type PermissionStage = "permission" | "always" | "reject"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
const cwd = process.cwd()
const home = Global.Path.home
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use ~ or absolute
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
return absolute.replace(home, "~")
}
return input
return absolute
}
function filetype(input?: string) {
@@ -226,7 +237,23 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
{(() => {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string"
? pattern.includes("*")
? path.dirname(pattern)
: pattern
: undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
return <TextBody icon="←" title={`Access external directory ` + dir} />
})()}
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
@@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const other = createMemo(() => store.selected === options().length)
const custom = createMemo(() => question()?.custom !== false)
const other = createMemo(() => custom() && store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
@@ -131,6 +132,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("editing", false)
return
}
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
@@ -141,16 +152,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
}
const answers = [...store.answers]
if (prev) {
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
if (!prev) {
answers[store.tab] = []
}
setStore("answers", answers)
setStore("editing", false)
return
}
@@ -203,7 +209,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
} else {
const opts = options()
const total = opts.length + 1 // options + "Other"
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
@@ -286,11 +302,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
{i() + 1}. {opt.label}
{multi()
? `${i() + 1}. [${picked() ? "✓" : " "}] ${opt.label}`
: `${i() + 1}. ${opt.label}`}
</text>
</box>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
<Show when={!multi()}>
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
</Show>
</box>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{opt.description}</text>
</box>
@@ -298,35 +319,46 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{options().length + 1}. Type your own answer
</text>
<Show when={custom()}>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
{multi()
? `${options().length + 1}. [${customPicked() ? "✓" : " "}] Type your own answer`
: `${options().length + 1}. Type your own answer`}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</Show>
</box>
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => {
textarea = val
queueMicrotask(() => {
val.focus()
val.gotoLineEnd()
})
}}
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
<Show when={store.editing}>
<box paddingLeft={3}>
<textarea
ref={(val: TextareaRenderable) => (textarea = val)}
focused
initialValue={input()}
placeholder="Type your own answer"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
</box>
</Show>
</box>
</Show>
</box>
</box>
</Show>
@@ -340,9 +372,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{q.header}:</text>
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
<span style={{ fg: answered() ? theme.text : theme.error }}>
{answered() ? value() : "(not answered)"}
</span>
</text>
</box>
)
}}
+16 -1
View File
@@ -9,6 +9,7 @@ import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -50,6 +51,8 @@ const startEventStream = (directory: string) => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const auth = getAuthorizationHeader()
if (auth) request.headers.set("Authorization", auth)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
@@ -95,9 +98,14 @@ startEventStream(process.cwd())
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = getAuthorizationHeader()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
const request = new Request(input.url, {
method: input.method,
headers: input.headers,
headers,
body: input.body,
})
const response = await Server.App().fetch(request)
@@ -135,3 +143,10 @@ export const rpc = {
}
Rpc.listen(rpc)
function getAuthorizationHeader(): string | undefined {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${btoa(`${username}:${password}`)}`
}
+9 -3
View File
@@ -16,7 +16,7 @@ export const UpgradeCommand = {
alias: "m",
describe: "installation method to use",
type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew"],
choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"],
})
},
handler: async (args: { target?: string; method?: string }) => {
@@ -56,8 +56,14 @@ export const UpgradeCommand = {
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) prompts.log.error(err.data.stderr)
else if (err instanceof Error) prompts.log.error(err.message)
if (err instanceof Installation.UpgradeFailedError) {
// necessary because choco only allows install/upgrade in elevated terminals
if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
prompts.log.error("Please run the terminal as Administrator and try again")
} else {
prompts.log.error(err.data.stderr)
}
} else if (err instanceof Error) prompts.log.error(err.message)
prompts.outro("Done")
return
}
+1
View File
@@ -38,6 +38,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
+46 -5
View File
@@ -83,6 +83,14 @@ export namespace Installation {
name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
},
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
},
]
checks.sort((a, b) => {
@@ -95,7 +103,9 @@ export namespace Installation {
for (const check of checks) {
const output = await check.command()
if (output.includes(check.name === "brew" ? "opencode" : "opencode-ai")) {
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}
@@ -144,20 +154,28 @@ export namespace Installation {
})
break
}
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
break
case "scoop":
cmd = $`scoop install extras/opencode@${target}`
break
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
throw new UpgradeFailedError({
stderr: stderr,
})
}
log.info("upgraded", {
method,
target,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
if (result.exitCode !== 0)
throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
}
@@ -195,6 +213,29 @@ export namespace Installation {
.then((data: any) => data.version)
}
if (detectedMethod === "choco") {
return fetch(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
{ headers: { Accept: "application/json;odata=verbose" } },
)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.d.results[0].Version)
}
if (detectedMethod === "scoop") {
return fetch("https://raw.githubusercontent.com/ScoopInstaller/Extras/master/bucket/opencode.json", {
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
+14
View File
@@ -266,6 +266,13 @@ export namespace MCP {
status: s.status,
}
}
// Close existing client if present to prevent memory leaks
const existingClient = s.clients[name]
if (existingClient) {
await existingClient.close().catch((error) => {
log.error("Failed to close existing MCP client", { name, error })
})
}
s.clients[name] = result.mcpClient
s.status[name] = result.status
@@ -523,6 +530,13 @@ export namespace MCP {
const s = await state()
s.status[name] = result.status
if (result.mcpClient) {
// Close existing client if present to prevent memory leaks
const existingClient = s.clients[name]
if (existingClient) {
await existingClient.close().catch((error) => {
log.error("Failed to close existing MCP client", { name, error })
})
}
s.clients[name] = result.mcpClient
}
}
+1
View File
@@ -387,6 +387,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
headers: {},
release_date: "2025-12-18",
variants: {} as Record<string, Record<string, any>>,
family: "gpt-codex",
}
model.variants = ProviderTransform.variants(model)
provider.models["gpt-5.2-codex"] = model
+6 -1
View File
@@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"]
const BUILTIN = [
"opencode-copilot-auth@0.0.12",
"opencode-anthropic-auth@0.0.8",
"@gitlab/opencode-gitlab-auth@1.3.0",
]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
@@ -46,6 +50,7 @@ export namespace Plugin {
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN)
}
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth")) continue
+1
View File
@@ -58,6 +58,7 @@ export namespace State {
tasks.push(task)
}
entries.clear()
recordsByKey.delete(key)
await Promise.all(tasks)
disposalFinished = true
log.info("state disposal completed", { key })
@@ -35,6 +35,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { createGitLab } from "@gitlab/gitlab-ai-provider"
import { ProviderTransform } from "./transform"
export namespace Provider {
@@ -60,6 +61,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
"@gitlab/gitlab-ai-provider": createGitLab,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -390,6 +392,41 @@ export namespace Provider {
},
}
},
async gitlab(input) {
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
const auth = await Auth.get(input.id)
const apiKey = await (async () => {
if (auth?.type === "oauth") return auth.access
if (auth?.type === "api") return auth.key
return Env.get("GITLAB_TOKEN")
})()
const config = await Config.get()
const providerConfig = config.provider?.["gitlab"]
return {
autoload: !!apiKey,
options: {
instanceUrl,
apiKey,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
...(providerConfig?.options?.featureFlags || {}),
},
})
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
+1
View File
@@ -24,6 +24,7 @@ export namespace Question {
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({
ref: "QuestionInfo",
+4
View File
@@ -724,6 +724,8 @@ export namespace Server {
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
@@ -737,6 +739,8 @@ export namespace Server {
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
sessions.push(session)
+12
View File
@@ -1,3 +1,5 @@
import { Slug } from "@opencode-ai/util/slug"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -19,6 +21,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -39,6 +42,7 @@ export namespace Session {
export const Info = z
.object({
id: Identifier.schema("session"),
slug: z.string(),
projectID: z.string(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
@@ -194,6 +198,7 @@ export namespace Session {
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
@@ -227,6 +232,13 @@ export namespace Session {
return result
}
export function plan(input: { slug: string; time: { created: number } }) {
const base = Instance.project.vcs
? path.join(Instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}
export const get = fn(Identifier.schema("session"), async (id) => {
const read = await Storage.read<Info>(["session", Instance.project.id, id])
return read as Info
+10 -7
View File
@@ -55,13 +55,20 @@ export namespace LLM {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg] = await Promise.all([Provider.getLanguage(input.model), Config.get()])
const [language, cfg, provider, auth] = await Promise.all([
Provider.getLanguage(input.model),
Config.get(),
Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID),
])
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const system = SystemPrompt.header(input.model.providerID)
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
@@ -84,10 +91,6 @@ export namespace LLM {
system.push(header, rest.join("\n"))
}
const provider = await Provider.getProvider(input.model.providerID)
const auth = await Auth.get(input.model.providerID)
const isCodex = provider.id === "openai" && auth?.type === "oauth"
const variant =
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const base = input.small
@@ -110,7 +113,7 @@ export namespace LLM {
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
provider: Provider.getProvider(input.model.providerID),
provider,
message: input.user,
},
{
+129 -16
View File
@@ -510,9 +510,10 @@ export namespace SessionPrompt {
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = insertReminders({
msgs = await insertReminders({
messages: msgs,
agent,
session,
})
const processor = SessionProcessor.create({
@@ -1185,30 +1186,142 @@ export namespace SessionPrompt {
}
}
function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
// TODO (for mr dax): update to use the anthropic full fledged one (see plan-reminder-anthropic.txt)
text: PROMPT_PLAN,
synthetic: true,
})
// Original logic when experimental plan mode is disabled
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
synthetic: true,
})
}
return input.messages
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
// New plan mode logic when flag is enabled
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
// Switching from plan mode to build mode
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
if (exists) {
const part = await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text:
BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`,
synthetic: true,
})
userMessage.parts.push(part)
}
return input.messages
}
// Entering plan mode
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
const plan = Session.plan(input.session)
const exists = await Bun.file(plan).exists()
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
const part = await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
text: `<system-reminder>
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
## Plan File Info:
${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`}
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
## Plan Workflow
### Phase 1: Initial Understanding
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
1. Focus on understanding the user's request and the code associated with their request
2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
- Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
### Phase 2: Design
Goal: Design an implementation approach.
Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
You can launch up to 1 agent(s) in parallel.
**Guidelines:**
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
Examples of when to use multiple agents:
- The task touches multiple parts of the codebase
- It's a large refactor or architectural change
- There are many edge cases to consider
- You'd benefit from exploring different approaches
Example perspectives by task type:
- New feature: simplicity vs performance vs maintainability
- Bug fix: root cause vs workaround vs prevention
- Refactoring: minimal change vs clean architecture
In the agent prompt:
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
- Describe requirements and constraints
- Request a detailed implementation plan
### Phase 3: Review
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
1. Read the critical files identified by agents to deepen your understanding
2. Ensure that the plans align with the user's original request
3. Use question tool to clarify any remaining questions with the user
### Phase 4: Final Plan
Goal: Write your final plan to the plan file (the only file you can edit).
- Include only your recommended approach, not all alternatives
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
- Include the paths of critical files to be modified
- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
### Phase 5: Call plan_exit tool
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
</system-reminder>`,
synthetic: true,
})
userMessage.parts.push(part)
return input.messages
}
return input.messages
}
+14
View File
@@ -0,0 +1,14 @@
Use this tool to suggest switching to plan agent when the user's request would benefit from planning before implementation.
If they explicitly mention wanting to create a plan ALWAYS call this tool first.
This tool will ask the user if they want to switch to plan agent.
Call this tool when:
- The user's request is complex and would benefit from planning first
- You want to research and design before making changes
- The task involves multiple files or significant architectural decisions
Do NOT call this tool:
- For simple, straightforward tasks
- When the user explicitly wants immediate implementation
+13
View File
@@ -0,0 +1,13 @@
Use this tool when you have completed the planning phase and are ready to exit plan agent.
This tool will ask the user if they want to switch to build agent to start implementing the plan.
Call this tool:
- After you have written a complete plan to the plan file
- After you have clarified any questions with the user
- When you are confident the plan is ready for implementation
Do NOT call this tool:
- Before you have created or finalized the plan
- If you still have unanswered questions about the implementation
- If the user has indicated they want to continue planning
+130
View File
@@ -0,0 +1,130 @@
import z from "zod"
import path from "path"
import { Tool } from "./tool"
import { Question } from "../question"
import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Provider } from "../provider/provider"
import { Instance } from "../project/instance"
import EXIT_DESCRIPTION from "./plan-exit.txt"
import ENTER_DESCRIPTION from "./plan-enter.txt"
async function getLastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
export const PlanExitTool = Tool.define("plan_exit", {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
header: "Build Agent",
custom: false,
options: [
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to build agent",
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
},
})
export const PlanEnterTool = Tool.define("plan_enter", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const session = await Session.get(ctx.sessionID)
const plan = path.relative(Instance.worktree, Session.plan(session))
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
header: "Plan Mode",
custom: false,
options: [
{ label: "Yes", description: "Switch to plan agent for research and planning" },
{ label: "No", description: "Stay with build agent to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
const model = await getLastModel(ctx.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: ctx.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "plan",
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
synthetic: true,
} satisfies MessageV2.TextPart)
return {
title: "Switching to plan agent",
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
metadata: {},
}
},
})
+1 -1
View File
@@ -6,7 +6,7 @@ import DESCRIPTION from "./question.txt"
export const QuestionTool = Tool.define("question", {
description: DESCRIPTION,
parameters: z.object({
questions: z.array(Question.Info).describe("Questions to ask"),
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
}),
async execute(params, ctx) {
const answers = await Question.ask({
+3 -1
View File
@@ -25,6 +25,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -93,7 +94,7 @@ export namespace ToolRegistry {
return [
InvalidTool,
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
@@ -109,6 +110,7 @@ export namespace ToolRegistry {
SkillTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...custom,
]
}
+1 -1
View File
@@ -87,7 +87,7 @@ export namespace Truncate {
await Bun.write(Bun.file(filepath), text)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
+1 -1
View File
@@ -60,7 +60,7 @@ export const WebFetchTool = Tool.define("webfetch", {
signal: AbortSignal.any([controller.signal, ctx.abort]),
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
Accept: acceptHeader,
"Accept-Language": "en-US,en;q=0.9",
},
+2 -2
View File
@@ -43,7 +43,7 @@ test("build agent has correct default properties", async () => {
})
})
test("plan agent denies edits except .opencode/plan/*", async () => {
test("plan agent denies edits except .opencode/plans/*", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -53,7 +53,7 @@ test("plan agent denies edits except .opencode/plan/*", async () => {
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(PermissionNext.evaluate("edit", ".opencode/plan/foo.md", plan!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
},
})
})
@@ -9,7 +9,11 @@ import path from "path"
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string) => pkg,
install: async (pkg: string, _version?: string) => {
// Return package name without version for mocking
const lastAtIndex = pkg.lastIndexOf("@")
return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
},
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
@@ -28,6 +32,7 @@ mock.module("@aws-sdk/credential-providers", () => ({
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
// Import after mocks are set up
const { tmpdir } = await import("../fixture/fixture")
@@ -0,0 +1,286 @@
import { test, expect, mock } from "bun:test"
import path from "path"
// === Mocks ===
// These mocks prevent real package installations during tests
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string, _version?: string) => {
// Return package name without version for mocking
const lastAtIndex = pkg.lastIndexOf("@")
return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
},
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
which: () => process.execPath,
InstallFailedError: class extends Error {},
},
}))
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
// Import after mocks are set up
const { tmpdir } = await import("../fixture/fixture")
const { Instance } = await import("../../src/project/instance")
const { Provider } = await import("../../src/provider/provider")
const { Env } = await import("../../src/env")
const { Global } = await import("../../src/global")
test("GitLab Duo: loads provider with API key from environment", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-gitlab-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].key).toBe("test-gitlab-token")
},
})
})
test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
instanceUrl: "https://gitlab.example.com",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com")
},
})
})
test("GitLab Duo: loads with OAuth token from auth.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const authPath = path.join(Global.Path.data, "auth.json")
await Bun.write(
authPath,
JSON.stringify({
gitlab: {
type: "oauth",
access: "test-access-token",
refresh: "test-refresh-token",
expires: Date.now() + 3600000,
},
}),
)
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
},
})
})
test("GitLab Duo: loads with Personal Access Token from auth.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
const authPath2 = path.join(Global.Path.data, "auth.json")
await Bun.write(
authPath2,
JSON.stringify({
gitlab: {
type: "api",
key: "glpat-test-pat-token",
},
}),
)
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].key).toBe("glpat-test-pat-token")
},
})
})
test("GitLab Duo: supports self-hosted instance configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
instanceUrl: "https://gitlab.company.internal",
apiKey: "glpat-internal-token",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal")
},
})
})
test("GitLab Duo: config apiKey takes precedence over environment variable", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
apiKey: "config-token",
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "env-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
},
})
})
test("GitLab Duo: supports feature flags configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
gitlab: {
options: {
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.featureFlags).toBeDefined()
expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
},
})
})
test("GitLab Duo: has multiple agentic chat models available", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
const models = Object.keys(providers["gitlab"].models)
expect(models.length).toBeGreaterThan(0)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")
expect(models).toContain("duo-chat-opus-4-5")
},
})
})
@@ -1,5 +1,27 @@
import { test, expect } from "bun:test"
import { test, expect, mock } from "bun:test"
import path from "path"
// Mock BunProc and default plugins to prevent actual installations during tests
mock.module("../../src/bun/index", () => ({
BunProc: {
install: async (pkg: string, _version?: string) => {
// Return package name without version for mocking
const lastAtIndex = pkg.lastIndexOf("@")
return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
},
run: async () => {
throw new Error("BunProc.run should not be called in tests")
},
which: () => process.execPath,
InstallFailedError: class extends Error {},
},
}))
const mockPlugin = () => ({})
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
describe("session.list", () => {
test("filters by directory", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const app = Server.App()
const first = await Session.create({})
const otherDir = path.join(projectRoot, "..", "__session_list_other")
const second = await Instance.provide({
directory: otherDir,
fn: async () => Session.create({}),
})
const response = await app.request(`/session?directory=${encodeURIComponent(projectRoot)}`)
expect(response.status).toBe(200)
const body = (await response.json()) as unknown[]
const ids = body
.map((s) => (typeof s === "object" && s && "id" in s ? (s as { id: string }).id : undefined))
.filter((x): x is string => typeof x === "string")
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
},
})
})
})
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test"
import { Lock } from "../../src/util/lock"
function tick() {
return new Promise<void>((r) => queueMicrotask(r))
}
async function flush(n = 5) {
for (let i = 0; i < n; i++) await tick()
}
describe("util.lock", () => {
test("writer exclusivity: blocks reads and other writes while held", async () => {
const key = "lock:" + Math.random().toString(36).slice(2)
const state = {
writer2: false,
reader: false,
writers: 0,
}
// Acquire writer1
using writer1 = await Lock.write(key)
state.writers++
expect(state.writers).toBe(1)
// Start writer2 candidate (should block)
const writer2Task = (async () => {
const w = await Lock.write(key)
state.writers++
expect(state.writers).toBe(1)
state.writer2 = true
// Hold for a tick so reader cannot slip in
await tick()
return w
})()
// Start reader candidate (should block)
const readerTask = (async () => {
const r = await Lock.read(key)
state.reader = true
return r
})()
// Flush microtasks and assert neither acquired
await flush()
expect(state.writer2).toBe(false)
expect(state.reader).toBe(false)
// Release writer1
writer1[Symbol.dispose]()
state.writers--
// writer2 should acquire next
const writer2 = await writer2Task
expect(state.writer2).toBe(true)
// Reader still blocked while writer2 held
await flush()
expect(state.reader).toBe(false)
// Release writer2
writer2[Symbol.dispose]()
state.writers--
// Reader should now acquire
const reader = await readerTask
expect(state.reader).toBe(true)
reader[Symbol.dispose]()
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
+9
View File
@@ -5,6 +5,15 @@ export type ToolContext = {
messageID: string
agent: string
abort: AbortSignal
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
ask(input: AskInput): Promise<void>
}
type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: { [key: string]: any }
}
export function tool<Args extends z.ZodRawShape>(input: {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
@@ -30,4 +30,4 @@
"publishConfig": {
"directory": "dist"
}
}
}
+2
View File
@@ -781,6 +781,7 @@ export class Session extends HeyApiClient {
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
roots?: boolean
start?: number
search?: string
limit?: number
@@ -793,6 +794,7 @@ export class Session extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "search" },
{ in: "query", key: "limit" },
+12
View File
@@ -545,6 +545,10 @@ export type QuestionInfo = {
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
@@ -706,6 +710,7 @@ export type PermissionRuleset = Array<PermissionRule>
export type Session = {
id: string
slug: string
projectID: string
directory: string
parentID?: string
@@ -2584,7 +2589,14 @@ export type SessionListData = {
body?: never
path?: never
query?: {
/**
* Filter sessions by project directory
*/
directory?: string
/**
* Only return root sessions (no parentID)
*/
roots?: boolean
/**
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
*/
+18 -2
View File
@@ -981,7 +981,16 @@
"name": "directory",
"schema": {
"type": "string"
}
},
"description": "Filter sessions by project directory"
},
{
"in": "query",
"name": "roots",
"schema": {
"type": "boolean"
},
"description": "Only return root sessions (no parentID)"
},
{
"in": "query",
@@ -7122,6 +7131,10 @@
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean"
},
"custom": {
"description": "Allow typing a custom answer (default: true)",
"type": "boolean"
}
},
"required": ["question", "header", "options"]
@@ -7507,6 +7520,9 @@
"type": "string",
"pattern": "^ses.*"
},
"slug": {
"type": "string"
},
"projectID": {
"type": "string"
},
@@ -7593,7 +7609,7 @@
"required": ["messageID"]
}
},
"required": ["id", "projectID", "directory", "title", "version", "time"]
"required": ["id", "slug", "projectID", "directory", "title", "version", "time"]
},
"Event.session.created": {
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.18",
"version": "1.1.20",
"type": "module",
"license": "MIT",
"exports": {
+8 -2
View File
@@ -25,6 +25,7 @@ export interface BasicToolProps {
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
onSubtitleClick?: () => void
}
@@ -35,8 +36,13 @@ export function BasicTool(props: BasicToolProps) {
if (props.forceOpen) setOpen(true)
})
const handleOpenChange = (value: boolean) => {
if (props.locked && !value) return
setOpen(value)
}
return (
<Collapsible open={open()} onOpenChange={setOpen}>
<Collapsible open={open()} onOpenChange={handleOpenChange}>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
@@ -95,7 +101,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails}>
<Show when={props.children && !props.hideDetails && !props.locked}>
<Collapsible.Arrow />
</Show>
</div>
+3 -3
View File
@@ -48,9 +48,9 @@ const icons = {
"settings-gear": ` <path d="M9.99999 1L18 5.49998L18 14.5001L9.99998 19L2 14.5003L2 5.49996L9.99999 1Z" stroke="currentColor" stroke-linecap="square"/><path d="M13.2941 10.0001C13.2941 11.8313 11.8193 13.3159 10 13.3159C8.18073 13.3159 6.7059 11.8313 6.7059 10.0001C6.7059 8.16879 8.18073 6.68425 10 6.68425C11.8193 6.68425 13.2941 8.16879 13.2941 10.0001Z" stroke="currentColor" stroke-linecap="square"/>`,
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
"layout-bottom": `<path d="M18.125 18.125L1.875 18.125L1.875 1.875L18.125 1.875L18.125 18.125ZM3.125 12.8308L3.125 16.875L16.875 16.875L16.875 12.8308L3.125 12.8308ZM3.125 3.125L3.125 11.5808L16.875 11.5808L16.875 3.125L3.125 3.125Z" fill="currentColor"/>`,
"layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
"layout-bottom-partial": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom-full": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
+3 -9
View File
@@ -10,15 +10,9 @@ export interface ListSearchProps {
autofocus?: boolean
}
export interface ListGroup<T> {
category: string
items: T[]
}
export interface ListProps<T> extends FilteredListProps<T> {
class?: string
children: (item: T) => JSX.Element
groupHeader?: (group: ListGroup<T>) => JSX.Element
emptyMessage?: string
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
onMove?: (item: T | undefined) => void
@@ -122,7 +116,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
setScrollRef,
})
function GroupHeader(groupProps: { category: string; children?: JSX.Element }): JSX.Element {
function GroupHeader(groupProps: { category: string }): JSX.Element {
const [stuck, setStuck] = createSignal(false)
const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
@@ -144,7 +138,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return (
<div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
{groupProps.children ?? groupProps.category}
{groupProps.category}
</div>
)
}
@@ -191,7 +185,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<GroupHeader category={group.category}>{props.groupHeader?.(group)}</GroupHeader>
<GroupHeader category={group.category} />
</Show>
<div data-slot="list-items">
<For each={group.items}>
+192 -1
View File
@@ -405,7 +405,8 @@
[data-component="tool-part-wrapper"] {
width: 100%;
&[data-permission="true"] {
&[data-permission="true"],
&[data-question="true"] {
position: sticky;
top: calc(2px + var(--sticky-header-height, 40px));
bottom: 0px;
@@ -490,3 +491,193 @@
justify-content: flex-end;
}
}
[data-component="question-prompt"] {
display: flex;
flex-direction: column;
padding: 12px;
background-color: var(--surface-inset-base);
border-radius: 0 0 6px 6px;
gap: 12px;
[data-slot="question-tabs"] {
display: flex;
gap: 4px;
flex-wrap: wrap;
[data-slot="question-tab"] {
padding: 4px 12px;
font-size: 13px;
border-radius: 4px;
background-color: var(--surface-base);
color: var(--text-base);
border: none;
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
&:hover {
background-color: var(--surface-base-hover);
}
&[data-active="true"] {
background-color: var(--surface-raised-base);
}
&[data-answered="true"] {
color: var(--text-strong);
}
}
}
[data-slot="question-content"] {
display: flex;
flex-direction: column;
gap: 8px;
[data-slot="question-text"] {
font-size: 14px;
color: var(--text-base);
line-height: 1.5;
}
}
[data-slot="question-options"] {
display: flex;
flex-direction: column;
gap: 4px;
[data-slot="question-option"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 12px;
background-color: var(--surface-base);
border: 1px solid var(--border-weaker-base);
border-radius: 6px;
cursor: pointer;
text-align: left;
width: 100%;
transition:
background-color 0.15s,
border-color 0.15s;
position: relative;
&:hover {
background-color: var(--surface-base-hover);
border-color: var(--border-default);
}
&[data-picked="true"] {
[data-component="icon"] {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-strong);
}
}
[data-slot="option-label"] {
font-size: 14px;
color: var(--text-base);
font-weight: 500;
}
[data-slot="option-description"] {
font-size: 12px;
color: var(--text-weak);
}
}
[data-slot="custom-input-form"] {
display: flex;
gap: 8px;
padding: 8px 0;
align-items: stretch;
[data-slot="custom-input"] {
flex: 1;
padding: 8px 12px;
font-size: 14px;
border: 1px solid var(--border-default);
border-radius: 6px;
background-color: var(--surface-base);
color: var(--text-base);
outline: none;
&:focus {
border-color: var(--border-focus);
}
&::placeholder {
color: var(--text-weak);
}
}
[data-component="button"] {
height: auto;
}
}
}
[data-slot="question-review"] {
display: flex;
flex-direction: column;
gap: 12px;
[data-slot="review-title"] {
display: none;
}
[data-slot="review-item"] {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
[data-slot="review-label"] {
color: var(--text-weak);
}
[data-slot="review-value"] {
color: var(--text-strong);
&[data-answered="false"] {
color: var(--text-weak);
}
}
}
}
[data-slot="question-actions"] {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
}
[data-component="question-answers"] {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 12px;
[data-slot="question-answer-item"] {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
[data-slot="question-text"] {
color: var(--text-weak);
}
[data-slot="answer-text"] {
color: var(--text-strong);
}
}
}
+317 -2
View File
@@ -22,7 +22,11 @@ import {
ToolPart,
UserMessage,
Todo,
QuestionRequest,
QuestionAnswer,
QuestionInfo,
} from "@opencode-ai/sdk/v2"
import { createStore } from "solid-js/store"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
@@ -238,6 +242,11 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
icon: "checklist",
title: "Read to-dos",
}
case "question":
return {
icon: "bubble-5",
title: "Questions",
}
default:
return {
icon: "mcp",
@@ -438,6 +447,7 @@ export interface ToolProps {
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -475,7 +485,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return next
})
const questionRequest = createMemo(() => {
const next = data.store.question?.[props.message.sessionID]?.[0]
if (!next || !next.tool) return undefined
if (next.tool!.callID !== part.callID) return undefined
return next
})
const [showPermission, setShowPermission] = createSignal(false)
const [showQuestion, setShowQuestion] = createSignal(false)
createEffect(() => {
const perm = permission()
@@ -487,9 +505,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}
})
createEffect(() => {
const question = questionRequest()
if (question) {
const timeout = setTimeout(() => setShowQuestion(true), 50)
onCleanup(() => clearTimeout(timeout))
} else {
setShowQuestion(false)
}
})
const [forceOpen, setForceOpen] = createSignal(false)
createEffect(() => {
if (permission()) setForceOpen(true)
if (permission() || questionRequest()) setForceOpen(true)
})
const respond = (response: "once" | "always" | "reject") => {
@@ -512,7 +540,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const render = ToolRegistry.render(part.tool) ?? GenericTool
return (
<div data-component="tool-part-wrapper" data-permission={showPermission()}>
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
@@ -549,6 +577,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part.state.status}
hideDetails={props.hideDetails}
forceOpen={forceOpen()}
locked={showPermission() || showQuestion()}
defaultOpen={props.defaultOpen}
/>
</Match>
@@ -568,6 +597,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</div>
</div>
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
)
}
@@ -1042,3 +1072,288 @@ ToolRegistry.register({
)
},
})
ToolRegistry.register({
name: "question",
render(props) {
const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
const completed = createMemo(() => answers().length > 0)
const subtitle = createMemo(() => {
const count = questions().length
if (count === 0) return ""
if (completed()) return `${count} answered`
return `${count} question${count > 1 ? "s" : ""}`
})
return (
<BasicTool
{...props}
defaultOpen={completed()}
icon="bubble-5"
trigger={{
title: "Questions",
subtitle: subtitle(),
}}
>
<Show when={completed()}>
<div data-component="question-answers">
<For each={questions()}>
{(q, i) => {
const answer = () => answers()[i()] ?? []
return (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<div data-slot="answer-text">{answer().join(", ") || "(no answer)"}</div>
</div>
)
}}
</For>
</div>
</Show>
</BasicTool>
)
},
})
function QuestionPrompt(props: { request: QuestionRequest }) {
const data = useData()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
editing: false,
})
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
data.replyToQuestion?.({
requestID: props.request.id,
answers,
})
}
function reject() {
data.rejectQuestion?.({
requestID: props.request.id,
})
}
function pick(answer: string, custom: boolean = false) {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
}
if (single()) {
data.replyToQuestion?.({
requestID: props.request.id,
answers: [[answer]],
})
return
}
setStore("tab", store.tab + 1)
}
function toggle(answer: string) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
}
function selectTab(index: number) {
setStore("tab", index)
setStore("editing", false)
}
function selectOption(optIndex: number) {
if (optIndex === options().length) {
setStore("editing", true)
return
}
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
function handleCustomSubmit(e: Event) {
e.preventDefault()
const value = input().trim()
if (!value) {
setStore("editing", false)
return
}
if (multi()) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(value, true)
setStore("editing", false)
}
return (
<div data-component="question-prompt">
<Show when={!single()}>
<div data-slot="question-tabs">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
<button
data-slot="question-tab"
data-active={active()}
data-answered={answered()}
onClick={() => selectTab(index())}
>
{q.header}
</button>
)
}}
</For>
<button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
Confirm
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " (select all that apply)" : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button data-slot="question-option" data-picked={picked()} onClick={() => selectOption(i())}>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For>
<button
data-slot="question-option"
data-picked={customPicked()}
onClick={() => selectOption(options().length)}
>
<span data-slot="option-label">Type your own answer</span>
<Show when={!store.editing && input()}>
<span data-slot="option-description">{input()}</span>
</Show>
<Show when={customPicked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder="Type your answer..."
value={input()}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}}
/>
<Button type="submit" variant="primary" size="small">
{multi() ? "Add" : "Submit"}
</Button>
<Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
Cancel
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">Review your answers</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : "(not answered)"}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject}>
Dismiss
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit}>
Submit
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={(store.answers[store.tab]?.length ?? 0) === 0}
>
Next
</Button>
</Show>
</Show>
</div>
</div>
)
}
+21 -1
View File
@@ -1,4 +1,13 @@
import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2"
import type {
Message,
Session,
Part,
FileDiff,
SessionStatus,
PermissionRequest,
QuestionRequest,
QuestionAnswer,
} from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -16,6 +25,9 @@ type Data = {
permission?: {
[sessionID: string]: PermissionRequest[]
}
question?: {
[sessionID: string]: QuestionRequest[]
}
message: {
[sessionID: string]: Message[]
}
@@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: {
response: "once" | "always" | "reject"
}) => void
export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void
export type QuestionRejectFn = (input: { requestID: string }) => void
export type NavigateToSessionFn = (sessionID: string) => void
export const { use: useData, provider: DataProvider } = createSimpleContext({
@@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
data: Data
directory: string
onPermissionRespond?: PermissionRespondFn
onQuestionReply?: QuestionReplyFn
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
}) => {
return {
@@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return props.directory
},
respondToPermission: props.onPermissionRespond,
replyToQuestion: props.onQuestionReply,
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
}
},
+4 -8
View File
@@ -24,16 +24,12 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
const [grouped, { refetch }] = createResource(
() => ({
filter: store.filter,
items:
typeof props.items === "function"
? props.items.length === 0
? (props.items as () => T[])()
: undefined
: props.items,
items: typeof props.items === "function" ? props.items(store.filter) : props.items,
}),
async ({ filter, items }) => {
const needle = filter?.toLowerCase()
const all = (items ?? (await (props.items as (filter: string) => T[] | Promise<T[]>)(needle))) || []
const query = filter ?? ""
const needle = query.toLowerCase()
const all = (await Promise.resolve(items)) || []
const result = pipe(
all,
(x) => {
+231
View File
@@ -0,0 +1,231 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkWorldBg": "#0B0B3B",
"darkWorldDeep": "#050520",
"darkWorldPanel": "#151555",
"krisBlue": "#6A7BC4",
"krisCyan": "#75FBED",
"krisIce": "#C7E3F2",
"susiePurple": "#5B209D",
"susieMagenta": "#A017D0",
"susiePink": "#F983D8",
"ralseiGreen": "#33A56C",
"ralseiTeal": "#40E4D4",
"noelleRose": "#DC8998",
"noelleRed": "#DC1510",
"noelleMint": "#ECFFBB",
"noelleCyan": "#77E0FF",
"noelleAqua": "#BBFFFC",
"gold": "#FBCE3C",
"orange": "#F4A731",
"hotPink": "#EB0095",
"queenPink": "#F983D8",
"cyberGreen": "#00FF00",
"white": "#FFFFFF",
"black": "#000000",
"textMuted": "#8888AA"
},
"theme": {
"primary": {
"dark": "hotPink",
"light": "susieMagenta"
},
"secondary": {
"dark": "krisCyan",
"light": "krisBlue"
},
"accent": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"error": {
"dark": "noelleRed",
"light": "noelleRed"
},
"warning": {
"dark": "gold",
"light": "orange"
},
"success": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"info": {
"dark": "noelleCyan",
"light": "krisBlue"
},
"text": {
"dark": "white",
"light": "black"
},
"textMuted": {
"dark": "textMuted",
"light": "#555577"
},
"background": {
"dark": "darkWorldBg",
"light": "white"
},
"backgroundPanel": {
"dark": "darkWorldDeep",
"light": "#F0F0F8"
},
"backgroundElement": {
"dark": "darkWorldPanel",
"light": "#E5E5F0"
},
"border": {
"dark": "krisBlue",
"light": "susiePurple"
},
"borderActive": {
"dark": "hotPink",
"light": "susieMagenta"
},
"borderSubtle": {
"dark": "#3A3A6A",
"light": "#AAAACC"
},
"diffAdded": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"diffRemoved": {
"dark": "hotPink",
"light": "noelleRed"
},
"diffContext": {
"dark": "textMuted",
"light": "#666688"
},
"diffHunkHeader": {
"dark": "krisBlue",
"light": "susiePurple"
},
"diffHighlightAdded": {
"dark": "ralseiGreen",
"light": "ralseiTeal"
},
"diffHighlightRemoved": {
"dark": "noelleRed",
"light": "hotPink"
},
"diffAddedBg": {
"dark": "#0A2A2A",
"light": "#D4FFEE"
},
"diffRemovedBg": {
"dark": "#2A0A2A",
"light": "#FFD4E8"
},
"diffContextBg": {
"dark": "darkWorldDeep",
"light": "#F5F5FA"
},
"diffLineNumber": {
"dark": "textMuted",
"light": "#666688"
},
"diffAddedLineNumberBg": {
"dark": "#082020",
"light": "#E0FFF0"
},
"diffRemovedLineNumberBg": {
"dark": "#200820",
"light": "#FFE0F0"
},
"markdownText": {
"dark": "white",
"light": "black"
},
"markdownHeading": {
"dark": "gold",
"light": "orange"
},
"markdownLink": {
"dark": "krisCyan",
"light": "krisBlue"
},
"markdownLinkText": {
"dark": "noelleCyan",
"light": "susiePurple"
},
"markdownCode": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"markdownBlockQuote": {
"dark": "textMuted",
"light": "#666688"
},
"markdownEmph": {
"dark": "susiePink",
"light": "susieMagenta"
},
"markdownStrong": {
"dark": "hotPink",
"light": "susiePurple"
},
"markdownHorizontalRule": {
"dark": "krisBlue",
"light": "susiePurple"
},
"markdownListItem": {
"dark": "gold",
"light": "orange"
},
"markdownListEnumeration": {
"dark": "krisCyan",
"light": "krisBlue"
},
"markdownImage": {
"dark": "susieMagenta",
"light": "susiePurple"
},
"markdownImageText": {
"dark": "susiePink",
"light": "susieMagenta"
},
"markdownCodeBlock": {
"dark": "white",
"light": "black"
},
"syntaxComment": {
"dark": "textMuted",
"light": "#666688"
},
"syntaxKeyword": {
"dark": "hotPink",
"light": "susieMagenta"
},
"syntaxFunction": {
"dark": "krisCyan",
"light": "krisBlue"
},
"syntaxVariable": {
"dark": "gold",
"light": "orange"
},
"syntaxString": {
"dark": "ralseiTeal",
"light": "ralseiGreen"
},
"syntaxNumber": {
"dark": "noelleRose",
"light": "noelleRed"
},
"syntaxType": {
"dark": "noelleCyan",
"light": "krisBlue"
},
"syntaxOperator": {
"dark": "white",
"light": "black"
},
"syntaxPunctuation": {
"dark": "krisBlue",
"light": "#555577"
}
}
}
+232
View File
@@ -0,0 +1,232 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"black": "#000000",
"white": "#FFFFFF",
"soulRed": "#FF0000",
"soulOrange": "#FF6600",
"soulYellow": "#FFFF00",
"soulGreen": "#00FF00",
"soulAqua": "#00FFFF",
"soulBlue": "#0000FF",
"soulPurple": "#FF00FF",
"ruinsPurple": "#A349A4",
"ruinsDark": "#380A43",
"snowdinBlue": "#6BA3E5",
"hotlandOrange": "#FF7F27",
"coreGray": "#3A3949",
"battleBg": "#0D0D1A",
"battlePanel": "#1A1A2E",
"uiYellow": "#FFC90E",
"textGray": "#909090",
"damageRed": "#FF3333",
"healGreen": "#00FF00",
"saveYellow": "#FFFF00",
"determinationRed": "#FF0000",
"mttPink": "#FF6EB4",
"waterfall": "#283197",
"waterfallGlow": "#00BFFF"
},
"theme": {
"primary": {
"dark": "soulRed",
"light": "determinationRed"
},
"secondary": {
"dark": "uiYellow",
"light": "uiYellow"
},
"accent": {
"dark": "soulAqua",
"light": "soulBlue"
},
"error": {
"dark": "damageRed",
"light": "soulRed"
},
"warning": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"success": {
"dark": "healGreen",
"light": "soulGreen"
},
"info": {
"dark": "soulAqua",
"light": "waterfallGlow"
},
"text": {
"dark": "white",
"light": "black"
},
"textMuted": {
"dark": "textGray",
"light": "coreGray"
},
"background": {
"dark": "black",
"light": "white"
},
"backgroundPanel": {
"dark": "battleBg",
"light": "#F0F0F0"
},
"backgroundElement": {
"dark": "battlePanel",
"light": "#E5E5E5"
},
"border": {
"dark": "white",
"light": "black"
},
"borderActive": {
"dark": "soulRed",
"light": "determinationRed"
},
"borderSubtle": {
"dark": "#555555",
"light": "#AAAAAA"
},
"diffAdded": {
"dark": "healGreen",
"light": "soulGreen"
},
"diffRemoved": {
"dark": "damageRed",
"light": "soulRed"
},
"diffContext": {
"dark": "textGray",
"light": "coreGray"
},
"diffHunkHeader": {
"dark": "soulAqua",
"light": "soulBlue"
},
"diffHighlightAdded": {
"dark": "soulGreen",
"light": "healGreen"
},
"diffHighlightRemoved": {
"dark": "soulRed",
"light": "determinationRed"
},
"diffAddedBg": {
"dark": "#002200",
"light": "#CCFFCC"
},
"diffRemovedBg": {
"dark": "#220000",
"light": "#FFCCCC"
},
"diffContextBg": {
"dark": "battleBg",
"light": "#F5F5F5"
},
"diffLineNumber": {
"dark": "textGray",
"light": "coreGray"
},
"diffAddedLineNumberBg": {
"dark": "#001A00",
"light": "#E0FFE0"
},
"diffRemovedLineNumberBg": {
"dark": "#1A0000",
"light": "#FFE0E0"
},
"markdownText": {
"dark": "white",
"light": "black"
},
"markdownHeading": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"markdownLink": {
"dark": "soulAqua",
"light": "soulBlue"
},
"markdownLinkText": {
"dark": "waterfallGlow",
"light": "waterfall"
},
"markdownCode": {
"dark": "healGreen",
"light": "soulGreen"
},
"markdownBlockQuote": {
"dark": "textGray",
"light": "coreGray"
},
"markdownEmph": {
"dark": "mttPink",
"light": "soulPurple"
},
"markdownStrong": {
"dark": "soulRed",
"light": "determinationRed"
},
"markdownHorizontalRule": {
"dark": "white",
"light": "black"
},
"markdownListItem": {
"dark": "uiYellow",
"light": "uiYellow"
},
"markdownListEnumeration": {
"dark": "uiYellow",
"light": "uiYellow"
},
"markdownImage": {
"dark": "ruinsPurple",
"light": "soulPurple"
},
"markdownImageText": {
"dark": "mttPink",
"light": "ruinsPurple"
},
"markdownCodeBlock": {
"dark": "white",
"light": "black"
},
"syntaxComment": {
"dark": "textGray",
"light": "coreGray"
},
"syntaxKeyword": {
"dark": "soulRed",
"light": "determinationRed"
},
"syntaxFunction": {
"dark": "soulAqua",
"light": "soulBlue"
},
"syntaxVariable": {
"dark": "uiYellow",
"light": "hotlandOrange"
},
"syntaxString": {
"dark": "healGreen",
"light": "soulGreen"
},
"syntaxNumber": {
"dark": "mttPink",
"light": "soulPurple"
},
"syntaxType": {
"dark": "waterfallGlow",
"light": "waterfall"
},
"syntaxOperator": {
"dark": "white",
"light": "black"
},
"syntaxPunctuation": {
"dark": "textGray",
"light": "coreGray"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.18",
"version": "1.1.20",
"private": true,
"type": "module",
"license": "MIT",
+74
View File
@@ -0,0 +1,74 @@
export namespace Slug {
const ADJECTIVES = [
"brave",
"calm",
"clever",
"cosmic",
"crisp",
"curious",
"eager",
"gentle",
"glowing",
"happy",
"hidden",
"jolly",
"kind",
"lucky",
"mighty",
"misty",
"neon",
"nimble",
"playful",
"proud",
"quick",
"quiet",
"shiny",
"silent",
"stellar",
"sunny",
"swift",
"tidy",
"witty",
] as const
const NOUNS = [
"cabin",
"cactus",
"canyon",
"circuit",
"comet",
"eagle",
"engine",
"falcon",
"forest",
"garden",
"harbor",
"island",
"knight",
"lagoon",
"meadow",
"moon",
"mountain",
"nebula",
"orchid",
"otter",
"panda",
"pixel",
"planet",
"river",
"rocket",
"sailor",
"squid",
"star",
"tiger",
"wizard",
"wolf",
] as const
export function create() {
return [
ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)],
NOUNS[Math.floor(Math.random() * NOUNS.length)],
].join("-")
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.18",
"version": "1.1.20",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 460 KiB

+1 -1
View File
@@ -34,7 +34,7 @@ Build is the **default** mode with all tools enabled. This is the standard mode
A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default:
- `write` - Cannot create new files
- `edit` - Cannot modify existing files
- `edit` - Cannot modify existing files, except for files located at `.opencode/plans/*.md` to detail the plan itself
- `patch` - Cannot apply patches
- `bash` - Cannot execute shell commands

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