Merge branch 'dev' into black-page-transitions-design-updates
parent
2cb1c4cd6d
commit
6b63ea98d8
|
|
@ -1,4 +1,4 @@
|
|||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
|
||||
- To test opencode in `packages/opencode`, run `bun dev`.
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- the default branch in this repo is `dev`
|
||||
- The default branch in this repo is `dev`.
|
||||
|
|
|
|||
1
STATS.md
1
STATS.md
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
## Style Guide
|
||||
|
||||
- Try to keep things in one function unless composable or reusable
|
||||
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
|
||||
= obj` just reference it as obj.a and obj.b. this preserves context
|
||||
- AVOID `try`/`catch` where possible
|
||||
- AVOID using `any` type
|
||||
- PREFER single word variable names where possible
|
||||
- Use as many bun apis as possible like Bun.file()
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
# Avoid let statements
|
||||
|
||||
we don't like let statements, especially combined with if/else statements.
|
||||
prefer const
|
||||
|
||||
This is bad:
|
||||
We don't like `let` statements, especially combined with if/else statements.
|
||||
Prefer `const`.
|
||||
|
||||
Good:
|
||||
|
||||
|
|
@ -32,7 +29,7 @@ else foo = 2
|
|||
|
||||
# Avoid else statements
|
||||
|
||||
Prefer early returns or even using `iife` to avoid else statements
|
||||
Prefer early returns or using an `iife` to avoid else statements.
|
||||
|
||||
Good:
|
||||
|
||||
|
|
|
|||
67
bun.lock
67
bun.lock
|
|
@ -22,7 +22,7 @@
|
|||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.16",
|
||||
"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.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
|
@ -234,7 +234,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
|
|
@ -250,7 +250,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -278,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.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
|
@ -353,7 +354,7 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -373,7 +374,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
|
@ -384,7 +385,7 @@
|
|||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
|
|
@ -397,7 +398,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -437,7 +438,7 @@
|
|||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
|
@ -448,7 +449,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
|
@ -588,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=="],
|
||||
|
|
@ -908,6 +913,10 @@
|
|||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "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-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="],
|
||||
|
||||
"@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=="],
|
||||
|
|
@ -1602,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=="],
|
||||
|
|
@ -2322,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=="],
|
||||
|
|
@ -2544,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=="],
|
||||
|
|
@ -2772,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=="],
|
||||
|
|
@ -2804,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=="],
|
||||
|
|
@ -3080,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=="],
|
||||
|
|
@ -3522,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=="],
|
||||
|
|
@ -3688,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=="],
|
||||
|
|
@ -3880,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=="],
|
||||
|
|
@ -4030,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=="],
|
||||
|
|
@ -4272,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=="],
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -81,12 +81,13 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
|||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run opencode
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-e3pcCRHba4B5aYIvdteL+PYW2KHO6Ry1qO4DoMn+erE=",
|
||||
"aarch64-darwin": "sha256-xF9TVBw8aYloNbQLLd19ywwdPIHyS12ktMPhzO+cYx0="
|
||||
"x86_64-linux": "sha256-wENwhwRVfgoVyA9YNGcG+fAfu46JxK4xvNgiPbRt//s=",
|
||||
"aarch64-darwin": "sha256-vm1DYl1erlbaqz5NHHlnZEMuFmidr/UkS84nIqLJ96Q="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { useSync } from "@/context/sync"
|
|||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
|
|
@ -362,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(() =>
|
||||
|
|
@ -879,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
|
||||
}
|
||||
|
|
@ -942,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)
|
||||
}
|
||||
|
|
@ -1560,6 +1572,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
fallback={
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
|
|
@ -1569,6 +1584,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
<ModelSelectorPopover>
|
||||
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
|
||||
<Button as="div" variant="ghost">
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
|
|
@ -1583,10 +1601,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
|
||||
{local.model.variant.current() ?? "Default"}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.16",
|
||||
"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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,10 +26,4 @@ export const config = {
|
|||
commits: "6,500",
|
||||
monthlyUsers: "650,000",
|
||||
},
|
||||
|
||||
// Stripe
|
||||
stripe: {
|
||||
publishableKey:
|
||||
"pk_live_51OhXSKEclFNgdHcR9dDfYGwQeKuPfKo0IjA5kWBQIXKMFhE8QFd9bYLdPZC6klRKEgEkxJYSKuZg9U3FKHdLnF4300F9qLqMgP",
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
try {
|
||||
const body = (await event.request.json()) as { plan: string }
|
||||
const plan = body.plan
|
||||
|
||||
if (!plan || !["20", "100", "200"].includes(plan)) {
|
||||
return Response.json({ error: "Invalid plan" }, { status: 400 })
|
||||
}
|
||||
|
||||
const amount = parseInt(plan) * 100
|
||||
|
||||
const intent = await Billing.stripe().setupIntents.create({
|
||||
payment_method_types: ["card"],
|
||||
metadata: {
|
||||
plan,
|
||||
amount: amount.toString(),
|
||||
},
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
clientSecret: intent.client_secret,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating setup intent:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -3,12 +3,8 @@ import { AuthClient } from "~/context/auth"
|
|||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
// TODO
|
||||
// input.request.url http://localhost:3001/auth/authorize?continue=/black/subscribe
|
||||
const result = await AuthClient.authorize(
|
||||
new URL("/callback/subscribe?foo=bar", input.request.url).toString(),
|
||||
"code",
|
||||
)
|
||||
// result.url https://auth.frank.dev.opencode.ai/authorize?client_id=app&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fauth%2Fcallback&response_type=code&state=0d3fc834-bcbc-42dc-83ab-c25c2c43c7e3
|
||||
return Response.redirect(result.url + "&continue=" + url.searchParams.get("continue"), 302)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,6 +514,39 @@
|
|||
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;
|
||||
|
|
@ -554,6 +587,52 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { Match, Switch } from "solid-js"
|
||||
|
||||
export const plans = [
|
||||
{ id: "20", amount: 20, multiplier: null },
|
||||
{ id: "100", amount: 100, multiplier: "6x more usage than Black 20" },
|
||||
{ id: "200", amount: 200, multiplier: "21x more usage than Black 20" },
|
||||
{ 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 }) {
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export default function Black() {
|
|||
"view-transition-name": `amount-${plan.id}`,
|
||||
}}
|
||||
>
|
||||
${plan.amount}
|
||||
${plan.id}
|
||||
</span>
|
||||
<Show when={!isSelected()}>
|
||||
<span
|
||||
|
|
@ -126,12 +126,12 @@ export default function Black() {
|
|||
<li>Cancel your subscription at anytime</li>
|
||||
</ul>
|
||||
<div data-slot="actions">
|
||||
<button type="button" onClick={cancel} data-slot="cancel">
|
||||
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<A href={`/black/subscribe?plan=${plan.id}`} data-slot="continue">
|
||||
<a href={`/black/subscribe/${plan.id}`} data-slot="continue">
|
||||
Continue
|
||||
</A>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
import { A, createAsync, query, redirect, useSearchParams } from "@solidjs/router"
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { createEffect, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { loadStripe } from "@stripe/stripe-js"
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "solid-stripe"
|
||||
import { config } from "~/config"
|
||||
import { Plan, PlanIcon, plans } from "./common"
|
||||
import { getActor } 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"
|
||||
|
||||
const plansMap = Object.fromEntries(plans.map((p) => [p.id, p])) as Record<string, (typeof plans)[number]>
|
||||
|
||||
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,
|
||||
})
|
||||
.from(UserTable)
|
||||
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(UserTable.accountID, Actor.account()),
|
||||
isNull(WorkspaceTable.timeDeleted),
|
||||
isNull(UserTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}, "black.subscribe.workspaces")
|
||||
|
||||
function CheckoutForm(props: { plan: Plan["id"]; amount: number }) {
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (!stripe() || !elements()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await elements()!.submit()
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "An error occurred")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: confirmError } = await stripe()!.confirmSetup({
|
||||
elements: elements()!,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}/black/success?plan=${props.plan}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (confirmError) {
|
||||
setError(confirmError.message ?? "An error occurred")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} data-slot="checkout-form">
|
||||
<PaymentElement />
|
||||
<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.amount}`}
|
||||
</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 | null>(null)
|
||||
|
||||
const [params] = useSearchParams()
|
||||
const plan = (params.plan as Plan["id"]) || "200"
|
||||
const planData = plansMap[plan] || plansMap["200"]
|
||||
|
||||
const [clientSecret, setClientSecret] = createSignal<string | null>(null)
|
||||
const [stripePromise] = createSignal(loadStripe(config.stripe.publishableKey))
|
||||
|
||||
// Auto-select if only one workspace
|
||||
createEffect(() => {
|
||||
const ws = workspaces()
|
||||
if (ws?.length === 1 && !selectedWorkspace()) {
|
||||
setSelectedWorkspace(ws[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
// Keyboard navigation for workspace picker
|
||||
const { active, setActive, onKeyDown } = createList({
|
||||
items: () => workspaces()?.map((w) => w.id) ?? [],
|
||||
initialActive: null,
|
||||
})
|
||||
|
||||
const handleSelectWorkspace = (id: string) => {
|
||||
setSelectedWorkspace(id)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const response = await fetch("/api/black/setup-intent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.clientSecret) {
|
||||
setClientSecret(data.clientSecret)
|
||||
}
|
||||
})
|
||||
|
||||
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">
|
||||
<div data-slot="plan-header">
|
||||
<p data-slot="title">Subscribe to OpenCode Black</p>
|
||||
<div data-slot="icon">
|
||||
<PlanIcon plan={plan} />
|
||||
</div>
|
||||
<p data-slot="price">
|
||||
<span data-slot="amount">${planData.amount}</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">Add payment method</p>
|
||||
<Show
|
||||
when={clientSecret()}
|
||||
fallback={
|
||||
<div data-slot="loading">
|
||||
<p>Loading payment form...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Elements
|
||||
stripe={stripePromise()}
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CheckoutForm plan={plan} amount={planData.amount} />
|
||||
</Elements>
|
||||
</Show>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ export async function GET({ params: { platform } }: APIEvent) {
|
|||
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
|
||||
cf: {
|
||||
// in case gh releases has rate limits
|
||||
cacheTtl: 60 * 60 * 24,
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.16",
|
||||
"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),
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -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,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
|
|||
}
|
||||
|
||||
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -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,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.16"
|
||||
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.16/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.16/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.16/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.16/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.16/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,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.16",
|
||||
"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.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -394,6 +394,8 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest${envStr}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) {
|
|||
})),
|
||||
})
|
||||
} else {
|
||||
sdk.client.session.prompt({
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
})
|
||||
sdk.client.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
...selectedModel,
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: selectedModel,
|
||||
variant,
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
history.append({
|
||||
...store.prompt,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
|
|
@ -11,16 +11,28 @@ import { useSync } from "../../context/sync"
|
|||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
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) {
|
||||
|
|
@ -32,7 +44,9 @@ function filetype(input?: string) {
|
|||
}
|
||||
|
||||
function EditBody(props: { request: PermissionRequest }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const themeState = useTheme()
|
||||
const theme = themeState.theme
|
||||
const syntax = themeState.syntax
|
||||
const sync = useSync()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
|
|
@ -54,7 +68,7 @@ function EditBody(props: { request: PermissionRequest }) {
|
|||
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
||||
</box>
|
||||
<Show when={diff()}>
|
||||
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
|
||||
<scrollbox height="100%">
|
||||
<diff
|
||||
diff={diff()}
|
||||
view={view()}
|
||||
|
|
@ -74,7 +88,7 @@ function EditBody(props: { request: PermissionRequest }) {
|
|||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -172,86 +186,111 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||
message: message || undefined,
|
||||
})
|
||||
}}
|
||||
onCancel={() => setStore("stage", "permission")}
|
||||
onCancel={() => {
|
||||
setStore("stage", "permission")
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={store.stage === "permission"}>
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<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)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "doom_loop"}>
|
||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
escapeKey="reject"
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("stage", "always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
if (session()?.parentID) {
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
{(() => {
|
||||
const body = (
|
||||
<Prompt
|
||||
title="Permission required"
|
||||
body={
|
||||
<Switch>
|
||||
<Match when={props.request.permission === "edit"}>
|
||||
<EditBody request={props.request} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "read"}>
|
||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "glob"}>
|
||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "grep"}>
|
||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "list"}>
|
||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "bash"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={(input().description as string) ?? ""}
|
||||
description={("$ " + input().command) as string}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "task"}>
|
||||
<TextBody
|
||||
icon="#"
|
||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
||||
description={"◉ " + input().description}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.request.permission === "webfetch"}>
|
||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "websearch"}>
|
||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "codesearch"}>
|
||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
||||
</Match>
|
||||
<Match when={props.request.permission === "external_directory"}>
|
||||
{(() => {
|
||||
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" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||
escapeKey="reject"
|
||||
fullscreen
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("stage", "always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
if (session()?.parentID) {
|
||||
setStore("stage", "reject")
|
||||
return
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "reject",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return body
|
||||
})()}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
|
@ -327,14 +366,18 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
body: JSX.Element
|
||||
options: T
|
||||
escapeKey?: keyof T
|
||||
fullscreen?: boolean
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keys = Object.keys(props.options) as (keyof T)[]
|
||||
const [store, setStore] = createStore({
|
||||
selected: keys[0],
|
||||
expanded: false,
|
||||
})
|
||||
const diffKey = Keybind.parse("ctrl+f")[0]
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name == "h") {
|
||||
|
|
@ -360,17 +403,36 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
evt.preventDefault()
|
||||
props.onSelect(props.escapeKey)
|
||||
}
|
||||
|
||||
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("expanded", (v) => !v)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
||||
const renderer = useRenderer()
|
||||
|
||||
const content = () => (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.warning}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
{...(store.expanded
|
||||
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
|
||||
: {
|
||||
top: 0,
|
||||
maxHeight: 15,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
position: "relative",
|
||||
})}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<text fg={theme.warning}>{"△"}</text>
|
||||
<text fg={theme.text}>{props.title}</text>
|
||||
</box>
|
||||
|
|
@ -403,6 +465,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={props.fullscreen}>
|
||||
<text fg={theme.text}>
|
||||
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
|
|
@ -413,4 +480,10 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
|
||||
{content()}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}`)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,43 @@ 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, options?: { anthropicModel?: string }) {
|
||||
const anthropicModel = options?.anthropicModel
|
||||
return sdk.agenticChat(modelID, {
|
||||
anthropicModel,
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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: {},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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]()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -123,13 +123,13 @@
|
|||
|
||||
&[data-size="normal"] {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 6px;
|
||||
&[data-icon] {
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 6px;
|
||||
|
||||
/* text-12-medium */
|
||||
|
|
@ -137,7 +137,6 @@
|
|||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>`,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
|||
setScrollRef,
|
||||
})
|
||||
|
||||
function GroupHeader(props: { category: string }): JSX.Element {
|
||||
function GroupHeader(groupProps: { category: string }): JSX.Element {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
|
||||
|
|
@ -138,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}>
|
||||
{props.category}
|
||||
{groupProps.category}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { Accordion } from "./accordion"
|
|||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { ProviderIcon } from "./provider-icon"
|
||||
import type { IconName } from "./provider-icons/types"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Card } from "./card"
|
||||
|
|
@ -498,7 +500,13 @@ export function SessionTurn(
|
|||
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
|
||||
</Show>
|
||||
<Show when={(msg() as UserMessage).model?.modelID}>
|
||||
<span data-slot="session-turn-badge">{(msg() as UserMessage).model?.modelID}</span>
|
||||
<span data-slot="session-turn-badge" class="inline-flex items-center gap-1">
|
||||
<ProviderIcon
|
||||
id={(msg() as UserMessage).model!.providerID as IconName}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{(msg() as UserMessage).model?.modelID}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue