Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c48e6a116 | ||
|
|
ef266b2c74 | ||
|
|
0a1cdc7a58 | ||
|
|
2dec956a17 | ||
|
|
ef8388f0ee | ||
|
|
e5c5b5e872 | ||
|
|
a1c9a1b8c5 | ||
|
|
76b012139a | ||
|
|
02e5a19242 | ||
|
|
af967648cb | ||
|
|
504a668a26 | ||
|
|
5efb1c7b2d | ||
|
|
fd973d242e | ||
|
|
c3d8672753 | ||
|
|
fe8ef041f6 | ||
|
|
c841de947e | ||
|
|
825dfd48b1 | ||
|
|
923d114ffa | ||
|
|
b157fd10a7 | ||
|
|
67ebe68160 | ||
|
|
7b63c14154 | ||
|
|
cdc11cde2e | ||
|
|
9721223b7e | ||
|
|
35a626e711 | ||
|
|
bb7b0ff221 | ||
|
|
68b4038196 | ||
|
|
3109214900 | ||
|
|
86ccc3409b | ||
|
|
a89089c88f | ||
|
|
e617c5d689 | ||
|
|
31983ca5ff | ||
|
|
59e3b7409f | ||
|
|
b7ce46f7a1 | ||
|
|
82b8d8fa5d | ||
|
|
77c837eb1a | ||
|
|
db77cc9845 | ||
|
|
68043edae6 | ||
|
|
337681dbbf | ||
|
|
66afc034d1 | ||
|
|
11ab8de59f | ||
|
|
5f074edc3a | ||
|
|
56b5cdf883 | ||
|
|
fb0e1e4d8d | ||
|
|
b745b1593f | ||
|
|
7376c3f8e7 | ||
|
|
831e9bce51 | ||
|
|
5de73abd82 | ||
|
|
3adbbc1b23 | ||
|
|
c6c29b3dcf | ||
|
|
a687d7c15f | ||
|
|
0c6da69f39 | ||
|
|
c4930eb6b2 | ||
|
|
a24549fce7 | ||
|
|
c0f9b13630 | ||
|
|
98fd53fd5f | ||
|
|
5b02a3029e | ||
|
|
94e851c2a2 | ||
|
|
1658a3ff59 | ||
|
|
9c8bc64138 | ||
|
|
80f704ebbf | ||
|
|
4dae6d1fcf | ||
|
|
5d2cab39da | ||
|
|
6963f96d4b | ||
|
|
05a9e7ce7a | ||
|
|
896d18ab3f | ||
|
|
893888536a | ||
|
|
c6221fc8b3 | ||
|
|
ae67f43ff0 |
@@ -5,6 +5,9 @@ on:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
LOOKBACK_HOURS: 4
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
if: github.repository == 'sst/opencode'
|
||||
@@ -25,9 +28,9 @@ jobs:
|
||||
- name: Get recent commits
|
||||
id: commits
|
||||
run: |
|
||||
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||
COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No commits in the last 4 hours"
|
||||
echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours"
|
||||
echo "has_commits=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_commits=true" >> $GITHUB_OUTPUT
|
||||
@@ -47,7 +50,7 @@ jobs:
|
||||
model: opencode/gpt-5.2
|
||||
agent: docs
|
||||
prompt: |
|
||||
Review the following commits from the last 4 hours and identify any new features that may need documentation.
|
||||
Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation.
|
||||
|
||||
<recent_commits>
|
||||
${{ steps.commits.outputs.list }}
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DAYS_BEFORE_STALE: 90
|
||||
DAYS_BEFORE_CLOSE: 7
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,17 +17,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||
stale-issue-label: "stale"
|
||||
close-issue-message: |
|
||||
[automated] Closing due to 90+ days of inactivity.
|
||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||
|
||||
Feel free to reopen if you still need this!
|
||||
stale-issue-message: |
|
||||
[automated] This issue has had no activity for 90 days.
|
||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||
|
||||
It will be closed in 7 days if there's no new activity.
|
||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||
remove-stale-when-updated: true
|
||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||
start-date: "2025-12-27"
|
||||
|
||||
@@ -20,3 +20,7 @@ opencode.json
|
||||
a.out
|
||||
target
|
||||
.scripts
|
||||
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
logs/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
description: ALWAYS use this when writing docs
|
||||
color: "#38A3EE"
|
||||
---
|
||||
|
||||
You are an expert technical documentation writer
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mode: primary
|
||||
hidden: true
|
||||
model: opencode/claude-haiku-4-5
|
||||
color: "#44BA81"
|
||||
tools:
|
||||
"*": false
|
||||
"github-triage": true
|
||||
|
||||
@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Running against a different directory
|
||||
|
||||
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||
|
||||
```bash
|
||||
bun dev <directory>
|
||||
```
|
||||
|
||||
To run OpenCode in the root of the opencode repo itself:
|
||||
|
||||
```bash
|
||||
bun dev .
|
||||
```
|
||||
|
||||
### Building a "localcode"
|
||||
|
||||
To compile a standalone executable:
|
||||
|
||||
```bash
|
||||
./packages/opencode/script/build.ts --single
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
|
||||
```bash
|
||||
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||
```
|
||||
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
|
||||
- Core pieces:
|
||||
- `packages/opencode`: OpenCode core business logic & server.
|
||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||
|
||||
@@ -184,3 +184,4 @@
|
||||
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -173,7 +173,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
@@ -200,7 +200,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -245,7 +245,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -254,7 +254,7 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.57",
|
||||
"@ai-sdk/anthropic": "2.0.50",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.73",
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
@@ -269,7 +269,7 @@
|
||||
"@ai-sdk/openai-compatible": "1.0.27",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
@@ -347,7 +347,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -367,7 +367,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -391,7 +391,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -403,8 +403,10 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
@@ -416,6 +418,7 @@
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -426,7 +429,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -437,7 +440,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -566,7 +569,7 @@
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
|
||||
|
||||
@@ -1774,6 +1777,8 @@
|
||||
|
||||
"@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="],
|
||||
|
||||
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
|
||||
|
||||
"@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
@@ -2786,6 +2791,8 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
||||
|
||||
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -2876,6 +2883,8 @@
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -3898,38 +3907,24 @@
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
|
||||
|
||||
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
@@ -4284,6 +4279,8 @@
|
||||
|
||||
"jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -4308,7 +4305,7 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766840161,
|
||||
"narHash": "sha256-Ss/LHpJJsng8vz1Pe33RSGIWUOcqM1fjrehjUkdrWio=",
|
||||
"lastModified": 1766996594,
|
||||
"narHash": "sha256-SosfgQSqVmOkqVgNYJnxW5FvoIQX4grOcpIKNrIwz4o=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3edc4a30ed3903fdf6f90c837f961fa6b49582d1",
|
||||
"rev": "0744ef1b047f07d31d9962d757ffe38ec14a4d41",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg="
|
||||
"nodeModules": "sha256-2i/QMBzp9MalOXur36mXaDDU8R9G/0dODCODEQOnaCU="
|
||||
}
|
||||
|
||||
@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
|
||||
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
||||
|
||||
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
||||
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
|
||||
next = next.replace(/(\.\/)+/g, "./")
|
||||
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
|
||||
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||
next = next.replace(
|
||||
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
|
||||
"/$2",
|
||||
)
|
||||
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
||||
|
||||
if (next !== content) fs.writeFileSync(file, next)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function App() {
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={(p) => (
|
||||
<Show when={p.params.id || true} keyed>
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<PromptProvider>
|
||||
<Session />
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
function getFilename(input: string) {
|
||||
const parts = input.split("/")
|
||||
return parts[parts.length - 1] || input
|
||||
}
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const dialog = useDialog()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
const defaultName = createMemo(() => props.project.name || folderName())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.url || "",
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [dragOver, setDragOver] = createSignal(false)
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
setDragOver(false)
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}
|
||||
|
||||
function clearIcon() {
|
||||
setStore("iconUrl", "")
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (!props.project.id) return
|
||||
|
||||
setStore("saving", true)
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color, url: store.iconUrl },
|
||||
})
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label="Name"
|
||||
placeholder={folderName()}
|
||||
value={store.name}
|
||||
onChange={(v) => setStore("name", v)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Icon</label>
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||
"border-border-base hover:border-border-strong": !dragOver(),
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById("icon-upload")?.click()}
|
||||
>
|
||||
<Show
|
||||
when={store.iconUrl}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.iconUrl}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
|
||||
onClick={clearIcon}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-icon-base" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
|
||||
<span>Click or drag an image</span>
|
||||
<span>Recommended: 128x128px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!store.iconUrl}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Color</label>
|
||||
<div class="flex gap-2">
|
||||
<For each={AVATAR_COLOR_KEYS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-8 rounded-md transition-all"
|
||||
classList={{
|
||||
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
|
||||
store.color === color,
|
||||
}}
|
||||
style={{ background: getAvatarColors(color).background }}
|
||||
onClick={() => setStore("color", color)}
|
||||
>
|
||||
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
||||
{store.saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function SessionContextUsage() {
|
||||
const sync = useSync()
|
||||
@@ -35,19 +35,13 @@ export function SessionContextUsage() {
|
||||
{(ctx) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Tokens</span>
|
||||
<span class="text-left flex-1">{ctx().tokens}</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Usage</span>
|
||||
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<span class="opacity-70 text-right flex-1">Cost</span>
|
||||
<span class="text-left flex-1">{cost()}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<span class="opacity-70 text-right">Tokens</span>
|
||||
<span class="text-left">{ctx().tokens}</span>
|
||||
<span class="opacity-70 text-right">Usage</span>
|
||||
<span class="text-left">{ctx().percentage ?? 0}%</span>
|
||||
<span class="opacity-70 text-right">Cost</span>
|
||||
<span class="text-left">{cost()}</span>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { ErrorPage, type InitError } from "../pages/error"
|
||||
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
@@ -71,21 +71,19 @@ function createGlobalSync() {
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
children: Record<string, State>
|
||||
}>({
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
children: {},
|
||||
})
|
||||
|
||||
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
||||
function child(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
setGlobalStore("children", directory, {
|
||||
children[directory] = createStore<State>({
|
||||
project: "",
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
@@ -105,7 +103,6 @@ function createGlobalSync() {
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
children[directory] = createStore(globalStore.children[directory])
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
return children[directory]
|
||||
@@ -127,7 +124,7 @@ function createGlobalSync() {
|
||||
const updated = new Date(s.time.updated).getTime()
|
||||
return updated > fourHoursAgo
|
||||
})
|
||||
setStore("session", sessions)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
@@ -138,7 +135,7 @@ function createGlobalSync() {
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const [, setStore] = child(directory)
|
||||
const [store, setStore] = child(directory)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
directory,
|
||||
@@ -170,12 +167,32 @@ function createGlobalSync() {
|
||||
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
|
||||
permission: () =>
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, typeof x.data> = {}
|
||||
const grouped: Record<string, Permission[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
grouped[perm.sessionID] = grouped[perm.sessionID] ?? []
|
||||
grouped[perm.sessionID]!.push(perm)
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
setStore("permission", grouped)
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
@@ -246,7 +263,7 @@ function createGlobalSync() {
|
||||
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
|
||||
break
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
|
||||
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
|
||||
break
|
||||
case "session.status": {
|
||||
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
||||
@@ -328,23 +345,26 @@ function createGlobalSync() {
|
||||
break
|
||||
}
|
||||
case "permission.updated": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
const sessionID = event.properties.sessionID
|
||||
const permissions = store.permission[sessionID]
|
||||
if (!permissions) {
|
||||
setStore("permission", event.properties.sessionID, [event.properties])
|
||||
} else {
|
||||
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
if (result.found) {
|
||||
draft[result.index] = event.properties
|
||||
return
|
||||
}
|
||||
draft.push(event.properties)
|
||||
}),
|
||||
)
|
||||
setStore("permission", sessionID, [event.properties])
|
||||
break
|
||||
}
|
||||
|
||||
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("permission", sessionID, result.index, reconcile(event.properties))
|
||||
break
|
||||
}
|
||||
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "permission.replied": {
|
||||
|
||||
@@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
{
|
||||
...project,
|
||||
...(metadata ?? {}),
|
||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createEffect, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { Permission } from "@opencode-ai/sdk/v2/client"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
type PermissionsBySession = {
|
||||
[sessionID: string]: Permission[]
|
||||
}
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
}) => void
|
||||
|
||||
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
|
||||
|
||||
function shouldAutoAccept(perm: Permission) {
|
||||
return AUTO_ACCEPT_TYPES.has(perm.type)
|
||||
}
|
||||
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
name: "Permission",
|
||||
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
"permission.v1",
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
const responded = new Set<string>()
|
||||
const watches = new Map<string, () => void>()
|
||||
|
||||
function respond(perm: Permission) {
|
||||
if (responded.has(perm.id)) return
|
||||
responded.add(perm.id)
|
||||
props.onRespond({
|
||||
sessionID: perm.sessionID,
|
||||
permissionID: perm.id,
|
||||
response: "once",
|
||||
})
|
||||
}
|
||||
|
||||
function watch(sessionID: string) {
|
||||
if (watches.has(sessionID)) return
|
||||
|
||||
const dispose = createRoot((dispose) => {
|
||||
createEffect(() => {
|
||||
if (!store.autoAcceptEdits[sessionID]) return
|
||||
|
||||
const permissions = props.permissions[sessionID] ?? []
|
||||
permissions.length
|
||||
|
||||
for (const perm of permissions) {
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
respond(perm)
|
||||
}
|
||||
})
|
||||
|
||||
return dispose
|
||||
})
|
||||
|
||||
watches.set(sessionID, dispose)
|
||||
}
|
||||
|
||||
function unwatch(sessionID: string) {
|
||||
const dispose = watches.get(sessionID)
|
||||
if (!dispose) return
|
||||
dispose()
|
||||
watches.delete(sessionID)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
|
||||
for (const sessionID in store.autoAcceptEdits) {
|
||||
if (!store.autoAcceptEdits[sessionID]) continue
|
||||
watch(sessionID)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const dispose of watches.values()) dispose()
|
||||
watches.clear()
|
||||
})
|
||||
|
||||
function enable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, true)
|
||||
watch(sessionID)
|
||||
|
||||
const permissions = props.permissions[sessionID] ?? []
|
||||
for (const perm of permissions) {
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
respond(perm)
|
||||
}
|
||||
}
|
||||
|
||||
function disable(sessionID: string) {
|
||||
setStore("autoAcceptEdits", sessionID, false)
|
||||
unwatch(sessionID)
|
||||
}
|
||||
|
||||
return {
|
||||
get permissions() {
|
||||
return props.permissions
|
||||
},
|
||||
respond: props.onRespond,
|
||||
isAutoAccepting(sessionID: string) {
|
||||
return store.autoAcceptEdits[sessionID] ?? false
|
||||
},
|
||||
toggleAutoAccept(sessionID: string) {
|
||||
if (store.autoAcceptEdits[sessionID]) {
|
||||
disable(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
enable(sessionID)
|
||||
},
|
||||
enableAutoAccept(sessionID: string) {
|
||||
if (store.autoAcceptEdits[sessionID]) return
|
||||
enable(sessionID)
|
||||
},
|
||||
disableAutoAccept(sessionID: string) {
|
||||
disable(sessionID)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { produce } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, message)
|
||||
}
|
||||
draft.part[input.messageID] = input.parts.slice()
|
||||
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
retry(() => sdk.client.session.todo({ sessionID })),
|
||||
retry(() => sdk.client.session.diff({ sessionID })),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages
|
||||
.data!.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
|
||||
batch(() => {
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = session.data!
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, session.data!)
|
||||
}),
|
||||
)
|
||||
|
||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||
setStore(
|
||||
"message",
|
||||
sessionID,
|
||||
reconcile(
|
||||
(messages.data ?? [])
|
||||
.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
|
||||
for (const message of messages.data ?? []) {
|
||||
setStore(
|
||||
"part",
|
||||
message.info.id,
|
||||
reconcile(
|
||||
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
})
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
setStore("limit", (x) => x + count)
|
||||
@@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", sessions)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => store.session.length >= store.limit),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
@@ -19,16 +20,18 @@ export default function Layout(props: ParentProps) {
|
||||
{iife(() => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const respond = (input: {
|
||||
sessionID: string
|
||||
permissionID: string
|
||||
response: "once" | "always" | "reject"
|
||||
}) => sdk.client.permission.respond(input)
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
directory={directory()}
|
||||
onPermissionRespond={(input) => {
|
||||
sdk.client.permission.respond(input)
|
||||
}}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
|
||||
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
})}
|
||||
</SyncProvider>
|
||||
|
||||
@@ -20,11 +20,51 @@ function isInitError(error: unknown): error is InitError {
|
||||
)
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
const seen = new WeakSet<object>()
|
||||
const json = JSON.stringify(
|
||||
value,
|
||||
(_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString()
|
||||
if (typeof val === "object" && val) {
|
||||
if (seen.has(val)) return "[Circular]"
|
||||
seen.add(val)
|
||||
}
|
||||
return val
|
||||
},
|
||||
2,
|
||||
)
|
||||
return json ?? String(value)
|
||||
}
|
||||
|
||||
function formatInitError(error: InitError): string {
|
||||
const data = error.data
|
||||
switch (error.name) {
|
||||
case "MCPFailed":
|
||||
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
case "ProviderAuthError": {
|
||||
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||
const message = typeof data.message === "string" ? data.message : safeJson(data.message)
|
||||
return `Provider authentication failed (${providerID}): ${message}`
|
||||
}
|
||||
case "APIError": {
|
||||
const message = typeof data.message === "string" ? data.message : "API error"
|
||||
const lines: string[] = [message]
|
||||
|
||||
if (typeof data.statusCode === "number") {
|
||||
lines.push(`Status: ${data.statusCode}`)
|
||||
}
|
||||
|
||||
if (typeof data.isRetryable === "boolean") {
|
||||
lines.push(`Retryable: ${data.isRetryable}`)
|
||||
}
|
||||
|
||||
if (typeof data.responseBody === "string" && data.responseBody) {
|
||||
lines.push(`Response body:\n${data.responseBody}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
case "ProviderModelNotFoundError": {
|
||||
const { providerID, modelID, suggestions } = data as {
|
||||
providerID: string
|
||||
@@ -37,10 +77,14 @@ function formatInitError(error: InitError): string {
|
||||
`Check your config (opencode.json) provider/model names`,
|
||||
].join("\n")
|
||||
}
|
||||
case "ProviderInitError":
|
||||
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
|
||||
case "ConfigJsonError":
|
||||
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
|
||||
case "ProviderInitError": {
|
||||
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
|
||||
return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
|
||||
}
|
||||
case "ConfigJsonError": {
|
||||
const message = typeof data.message === "string" ? data.message : ""
|
||||
return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${message}` : "")
|
||||
}
|
||||
case "ConfigDirectoryTypoError":
|
||||
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
|
||||
case "ConfigFrontmatterError":
|
||||
@@ -51,14 +95,14 @@ function formatInitError(error: InitError): string {
|
||||
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
|
||||
)
|
||||
: []
|
||||
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
|
||||
"\n",
|
||||
)
|
||||
const message = typeof data.message === "string" ? data.message : ""
|
||||
return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
|
||||
}
|
||||
case "UnknownError":
|
||||
return String(data.message)
|
||||
return typeof data.message === "string" ? data.message : safeJson(data)
|
||||
default:
|
||||
return data.message ? String(data.message) : JSON.stringify(data, null, 2)
|
||||
if (typeof data.message === "string") return data.message
|
||||
return safeJson(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +113,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
const message = formatInitError(error)
|
||||
if (depth > 0 && parentMessage === message) return ""
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
return indent + message
|
||||
return indent + `${error.name}\n${message}`
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
@@ -77,15 +121,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
const parts: string[] = []
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
|
||||
if (!isDuplicate) {
|
||||
// Stack already includes error name and message, so prefer it
|
||||
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
|
||||
} else if (error.stack) {
|
||||
// Duplicate message - only show the stack trace lines (skip message)
|
||||
const trace = error.stack.split("\n").slice(1).join("\n").trim()
|
||||
if (trace) {
|
||||
parts.push(trace)
|
||||
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
|
||||
const stack = error.stack?.trim()
|
||||
|
||||
if (stack) {
|
||||
const startsWithHeader = stack.startsWith(header)
|
||||
|
||||
if (isDuplicate && startsWithHeader) {
|
||||
const trace = stack.split("\n").slice(1).join("\n").trim()
|
||||
if (trace) {
|
||||
parts.push(indent + trace)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate && !startsWithHeader) {
|
||||
parts.push(indent + stack)
|
||||
}
|
||||
|
||||
if (!isDuplicate && startsWithHeader) {
|
||||
parts.push(indent + stack)
|
||||
}
|
||||
|
||||
if (!isDuplicate && !startsWithHeader) {
|
||||
parts.push(indent + `${header}\n${stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!stack && !isDuplicate) {
|
||||
parts.push(indent + header)
|
||||
}
|
||||
|
||||
if (error.cause) {
|
||||
@@ -105,7 +168,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
|
||||
}
|
||||
|
||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
|
||||
return indent + JSON.stringify(error, null, 2)
|
||||
return indent + safeJson(error)
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
@@ -131,7 +194,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
readOnly
|
||||
copyable
|
||||
multiline
|
||||
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
|
||||
class="max-h-96 w-full font-mono text-xs no-scrollbar"
|
||||
label="Error Details"
|
||||
hideLabel
|
||||
/>
|
||||
|
||||
@@ -49,6 +49,7 @@ import { Header } from "@/components/header"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
|
||||
@@ -522,7 +523,7 @@ export default function Layout(props: ParentProps) {
|
||||
const notification = useNotification()
|
||||
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
@@ -558,7 +559,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
||||
return (
|
||||
<Switch>
|
||||
@@ -701,7 +702,7 @@ export default function Layout(props: ParentProps) {
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||
const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||
@@ -747,6 +748,11 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
||||
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -910,7 +916,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||
<Switch>
|
||||
<Match when={!providers.paid().length && expanded()}>
|
||||
<Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
|
||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">Getting started</div>
|
||||
@@ -929,7 +935,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Match when={providers.all().length > 0}>
|
||||
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||
|
||||
@@ -60,6 +60,14 @@ import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { StatusBar } from "@/components/status-bar"
|
||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
@@ -74,22 +82,40 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const permission = usePermission()
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => lastUserMessage()?.id,
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
if (msg.agent) local.agent.set(msg.agent)
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
},
|
||||
),
|
||||
)
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = revertMessageID()
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
@@ -159,16 +185,37 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", status.type !== "idle")
|
||||
})
|
||||
})
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id) => {
|
||||
const status = sync.data.session_status[id ?? ""] ?? idle
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", status.type !== "idle")
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => status().type,
|
||||
(type) => {
|
||||
if (type !== "idle") return
|
||||
batch(() => {
|
||||
setStore("userInteracted", false)
|
||||
setStore("stepsExpanded", false)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
|
||||
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
|
||||
|
||||
createRenderEffect((prev) => {
|
||||
@@ -304,6 +351,22 @@ export default function Page() {
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
},
|
||||
{
|
||||
id: "permissions.autoaccept",
|
||||
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
|
||||
category: "Permissions",
|
||||
disabled: !params.id,
|
||||
onSelect: () => {
|
||||
if (!params.id) return
|
||||
permission.toggleAutoAccept(params.id)
|
||||
showToast({
|
||||
title: permission.isAutoAccepting(params.id) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
|
||||
description: permission.isAutoAccepting(params.id)
|
||||
? "Edit and write permissions will be automatically approved"
|
||||
: "Edit and write permissions will require approval",
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.undo",
|
||||
title: "Undo",
|
||||
@@ -575,6 +638,7 @@ export default function Page() {
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
|
||||
onUserInteracted={() => setStore("userInteracted", true)}
|
||||
@@ -631,6 +695,7 @@ export default function Page() {
|
||||
<SessionTurn
|
||||
sessionID={params.id!}
|
||||
messageID={activeMessage()!.id}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
stepsExpanded={store.stepsExpanded}
|
||||
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
||||
onUserInteracted={() => setStore("userInteracted", true)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -124,6 +124,8 @@ export async function handler(
|
||||
res.status !== 200 &&
|
||||
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
|
||||
res.status !== 404 &&
|
||||
// ie. cannot change codex model providers mid-session
|
||||
!modelInfo.stickyProvider &&
|
||||
modelInfo.fallbackProvider &&
|
||||
providerInfo.id !== modelInfo.fallbackProvider
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
|
||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
esbuild: {
|
||||
// Improves production stack traces (less "kQ@..." noise)
|
||||
keepNames: true,
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.207"
|
||||
version = "1.0.209"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -50,7 +50,7 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.57",
|
||||
"@ai-sdk/anthropic": "2.0.50",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.73",
|
||||
"@ai-sdk/cerebras": "1.0.33",
|
||||
"@ai-sdk/cohere": "2.0.21",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@ai-sdk/openai-compatible": "1.0.27",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "3.0.19",
|
||||
"@ai-sdk/togetherai": "1.0.30",
|
||||
"@ai-sdk/xai": "2.0.42",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
|
||||
@@ -235,5 +235,19 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
|
||||
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ Rules:
|
||||
- Do not explain what the user asked for
|
||||
- Write in first person (I added..., I fixed...)
|
||||
- Never ask questions or add new questions
|
||||
- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an unanswered question to the user, preserve that exact question
|
||||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
|
||||
@@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) {
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
@@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) {
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import open from "open"
|
||||
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
children?: JSX.Element | string
|
||||
fg?: RGBA
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component that renders clickable hyperlinks.
|
||||
* Clicking anywhere on the link text opens the URL in the default browser.
|
||||
*/
|
||||
export function Link(props: LinkProps) {
|
||||
const displayText = props.children ?? props.href
|
||||
|
||||
return (
|
||||
<text
|
||||
fg={props.fg}
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -191,6 +191,10 @@ export namespace Config {
|
||||
cwd: dir,
|
||||
},
|
||||
).catch(() => {})
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
|
||||
|
||||
@@ -9,9 +9,13 @@ import path from "path"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { $ } from "bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { readdir } from "fs/promises"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
@@ -63,12 +67,16 @@ export namespace FileWatcher {
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
subs.push(
|
||||
await watcher().subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
}),
|
||||
)
|
||||
const pending = watcher().subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
})
|
||||
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
|
||||
log.error("failed to subscribe to Instance.directory", { error: err })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return undefined
|
||||
})
|
||||
if (sub) subs.push(sub)
|
||||
}
|
||||
|
||||
const vcsDir = await $`git rev-parse --git-dir`
|
||||
@@ -78,12 +86,18 @@ export namespace FileWatcher {
|
||||
.text()
|
||||
.then((x) => path.resolve(Instance.worktree, x.trim()))
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
subs.push(
|
||||
await watcher().subscribe(vcsDir, subscribe, {
|
||||
ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"],
|
||||
backend,
|
||||
}),
|
||||
)
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
const pending = watcher().subscribe(vcsDir, subscribe, {
|
||||
ignore: ignoreList,
|
||||
backend,
|
||||
})
|
||||
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
|
||||
log.error("failed to subscribe to vcsDir", { error: err })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return undefined
|
||||
})
|
||||
if (sub) subs.push(sub)
|
||||
}
|
||||
|
||||
return { subs }
|
||||
|
||||
@@ -322,3 +322,12 @@ export const shfmt: Info = {
|
||||
return Bun.which("shfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return Bun.which("nixfmt") !== null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,13 +196,14 @@ export namespace LSPServer {
|
||||
}
|
||||
await fs.rename(extractedPath, finalPath)
|
||||
|
||||
await $`npm install`.cwd(finalPath).quiet()
|
||||
await $`npm run compile`.cwd(finalPath).quiet()
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
await $`${npmCmd} install`.cwd(finalPath).quiet()
|
||||
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
|
||||
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
|
||||
const proc = spawn(BunProc.which(), ["--max-old-space-size=8192", serverPath, "--stdio"], {
|
||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -165,29 +165,44 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
const [awsProfile, awsAccessKeyId, awsBearerToken, awsRegion] = await Promise.all([
|
||||
Env.get("AWS_PROFILE"),
|
||||
Env.get("AWS_ACCESS_KEY_ID"),
|
||||
Env.get("AWS_BEARER_TOKEN_BEDROCK"),
|
||||
Env.get("AWS_REGION"),
|
||||
])
|
||||
const auth = await Auth.get("amazon-bedrock")
|
||||
const awsProfile = Env.get("AWS_PROFILE")
|
||||
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
||||
const awsRegion = Env.get("AWS_REGION")
|
||||
|
||||
const awsBearerToken = iife(() => {
|
||||
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
|
||||
if (envToken) return envToken
|
||||
if (auth?.type === "api") {
|
||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
|
||||
return auth.key
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
||||
|
||||
const region = awsRegion ?? "us-east-1"
|
||||
const defaultRegion = awsRegion ?? "us-east-1"
|
||||
|
||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
region,
|
||||
region: defaultRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
},
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
// Skip region prefixing if model already has global prefix
|
||||
if (modelID.startsWith("global.")) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
// Region resolution precedence (highest to lowest):
|
||||
// 1. options.region from opencode.json provider config
|
||||
// 2. defaultRegion from AWS_REGION environment variable
|
||||
// 3. Default "us-east-1" (baked into defaultRegion)
|
||||
const region = options?.region ?? defaultRegion
|
||||
|
||||
let regionPrefix = region.split("-")[0]
|
||||
|
||||
switch (regionPrefix) {
|
||||
|
||||
@@ -302,22 +302,22 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
export function smallOptions(model: Provider.Model) {
|
||||
const options: Record<string, any> = {}
|
||||
|
||||
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
|
||||
if (model.api.id.includes("5.")) {
|
||||
options["reasoningEffort"] = "low"
|
||||
} else {
|
||||
options["reasoningEffort"] = "minimal"
|
||||
return { reasoningEffort: "low" }
|
||||
}
|
||||
return { reasoningEffort: "minimal" }
|
||||
}
|
||||
if (model.providerID === "google") {
|
||||
options["thinkingConfig"] = {
|
||||
thinkingBudget: 0,
|
||||
}
|
||||
return { thinkingConfig: { thinkingBudget: 0 } }
|
||||
}
|
||||
|
||||
return options
|
||||
if (model.providerID === "openrouter") {
|
||||
if (model.api.id.includes("google")) {
|
||||
return { reasoning: { enabled: false } }
|
||||
}
|
||||
return { reasoningEffort: "minimal" }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
|
||||
|
||||
@@ -539,7 +539,7 @@ export namespace MessageV2 {
|
||||
}
|
||||
}
|
||||
|
||||
return convertToModelMessages(result.filter((msg) => msg.parts.length > 0))
|
||||
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
|
||||
}
|
||||
|
||||
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
|
||||
|
||||
@@ -32,44 +32,59 @@ export namespace Skill {
|
||||
}),
|
||||
)
|
||||
|
||||
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
|
||||
const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
|
||||
const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md")
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const directories = await Config.directories()
|
||||
const skills: Record<string, Info> = {}
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match)
|
||||
if (!md) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
// Warn on duplicate skill names
|
||||
if (skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of directories) {
|
||||
for await (const match of SKILL_GLOB.scan({
|
||||
for await (const match of OPENCODE_SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
})) {
|
||||
const md = await ConfigMarkdown.parse(match)
|
||||
if (!md) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) continue
|
||||
|
||||
// Warn on duplicate skill names
|
||||
if (skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
}
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
for await (const match of CLAUDE_SKILL_GLOB.scan({
|
||||
cwd: Instance.worktree,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
})) {
|
||||
await addSkill(match)
|
||||
}
|
||||
|
||||
return skills
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.describe("The format to return the content in (text, markdown, or html)"),
|
||||
.default("markdown")
|
||||
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
- Fetches content from a specified URL
|
||||
- Takes a URL and a prompt as input
|
||||
- Fetches the URL content, converts HTML to markdown
|
||||
- Returns the model's response about the content
|
||||
- Takes a URL and optional format as input
|
||||
- Fetches the URL content, converts to requested format (markdown by default)
|
||||
- Returns the content in the specified format
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
|
||||
Usage notes:
|
||||
- IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one.
|
||||
- The URL must be a fully-formed valid URL
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- The prompt should describe what information you want to extract from the page
|
||||
- Format options: "markdown" (default), "text", or "html"
|
||||
- This tool is read-only and does not modify any files
|
||||
- Results may be summarized if the content is very large
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Global } from "../../src/global"
|
||||
|
||||
test("Bedrock: config region takes precedence over AWS_REGION env var", 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: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_REGION", "us-east-1")
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// Region from config should be used (not env var)
|
||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: falls back to AWS_REGION env var when no config", 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("AWS_REGION", "eu-west-1")
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", 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("AWS_PROFILE", "default")
|
||||
// AWS_REGION might be set in the environment, use that or default
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// Should have some region set (either from env or default)
|
||||
expect(providers["amazon-bedrock"].options?.region).toBeDefined()
|
||||
expect(typeof providers["amazon-bedrock"].options?.region).toBe("string")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: uses config region in provider options", 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: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-north-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const bedrockProvider = providers["amazon-bedrock"]
|
||||
expect(bedrockProvider).toBeDefined()
|
||||
expect(bedrockProvider.options?.region).toBe("eu-north-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: respects config region for different instances", async () => {
|
||||
// First instance with EU config
|
||||
await using tmp1 = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp1.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
Env.set("AWS_REGION", "us-east-1")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers1 = await Provider.list()
|
||||
expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
|
||||
// Second instance with US config
|
||||
await using tmp2 = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp2.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
Env.set("AWS_REGION", "eu-west-1")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers2 = await Provider.list()
|
||||
expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: loads when bearer token from auth.json is present", 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: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// Setup auth.json with bearer token for amazon-bedrock
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
await Bun.write(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
"amazon-bedrock": {
|
||||
type: "api",
|
||||
key: "test-bearer-token",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
// Clear env vars so only auth.json should trigger autoload
|
||||
Env.set("AWS_PROFILE", "")
|
||||
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -101,31 +101,31 @@ test("returns empty array when no skills exist", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// test("discovers skills from .claude/skills/ directory", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// git: true,
|
||||
// init: async (dir) => {
|
||||
// const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
// await Bun.write(
|
||||
// path.join(skillDir, "SKILL.md"),
|
||||
// `---
|
||||
// name: claude-skill
|
||||
// description: A skill in the .claude/skills directory.
|
||||
// ---
|
||||
test("discovers skills from .claude/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
// # Claude Skill
|
||||
// `,
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
# Claude Skill
|
||||
`,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
// await Instance.provide({
|
||||
// directory: tmp.path,
|
||||
// fn: async () => {
|
||||
// const skills = await Skill.all()
|
||||
// expect(skills.length).toBe(1)
|
||||
// expect(skills[0].name).toBe("claude-skill")
|
||||
// expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("claude-skill")
|
||||
expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
@@ -25,15 +25,16 @@
|
||||
"generate:tailwind": "bun run script/tailwind.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/katex": "0.16.7",
|
||||
"@types/luxon": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:"
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -46,8 +47,10 @@
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.6",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
[data-component="image-preview"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="image-preview-container"] {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
width: min(calc(100vw - 32px), 90vw);
|
||||
max-width: 1200px;
|
||||
height: min(calc(100vh - 32px), 90vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[data-slot="image-preview-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow:
|
||||
0 15px 45px 0 rgba(19, 16, 16, 0.35),
|
||||
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
|
||||
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-header"] {
|
||||
display: flex;
|
||||
padding: 8px 8px 0;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-body"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-slot="image-preview-image"] {
|
||||
max-width: 100%;
|
||||
max-height: calc(90vh - 100px);
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Dialog as Kobalte } from "@kobalte/core/dialog"
|
||||
import { IconButton } from "./icon-button"
|
||||
|
||||
export interface ImagePreviewProps {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export function ImagePreview(props: ImagePreviewProps) {
|
||||
return (
|
||||
<div data-component="image-preview">
|
||||
<div data-slot="image-preview-container">
|
||||
<Kobalte.Content data-slot="image-preview-content">
|
||||
<div data-slot="image-preview-header">
|
||||
<Kobalte.CloseButton data-slot="image-preview-close" as={IconButton} icon="close" variant="ghost" />
|
||||
</div>
|
||||
<div data-slot="image-preview-body">
|
||||
<img src={props.src} alt={props.alt ?? "Image preview"} data-slot="image-preview-image" />
|
||||
</div>
|
||||
</Kobalte.Content>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, createSignal, For, type JSX, on, Show } from "solid-js"
|
||||
import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
@@ -116,6 +116,33 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
setScrollRef,
|
||||
})
|
||||
|
||||
function GroupHeader(props: { category: string }): JSX.Element {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
|
||||
createEffect(() => {
|
||||
const scroll = scrollRef()
|
||||
const node = header()
|
||||
if (!scroll || !node) return
|
||||
|
||||
const handler = () => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
const scrollRect = scroll.getBoundingClientRect()
|
||||
setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
|
||||
}
|
||||
|
||||
scroll.addEventListener("scroll", handler, { passive: true })
|
||||
handler()
|
||||
onCleanup(() => scroll.removeEventListener("scroll", handler))
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
|
||||
{props.category}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
|
||||
<Show when={!!props.search}>
|
||||
@@ -157,31 +184,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
{(group) => (
|
||||
<div data-slot="list-group">
|
||||
<Show when={group.category}>
|
||||
{(() => {
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
return (
|
||||
<div
|
||||
data-slot="list-header"
|
||||
data-stuck={stuck()}
|
||||
ref={(el) => {
|
||||
createEffect(() => {
|
||||
const scroll = scrollRef()
|
||||
if (!scroll) return
|
||||
const handler = () => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const scrollRect = scroll.getBoundingClientRect()
|
||||
setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
|
||||
}
|
||||
scroll.addEventListener("scroll", handler, { passive: true })
|
||||
handler()
|
||||
return () => scroll.removeEventListener("scroll", handler)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{group.category}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<GroupHeader category={group.category} />
|
||||
</Show>
|
||||
<div data-slot="list-items">
|
||||
<For each={group.items}>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
color: var(--text-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: var(--line-height-large);
|
||||
line-height: var(--line-height-x-large);
|
||||
|
||||
/* Spacing for flow */
|
||||
> *:first-child {
|
||||
@@ -64,8 +64,16 @@
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
@@ -117,13 +125,15 @@
|
||||
:not(pre) > code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
color: var(--syntax-string);
|
||||
font-weight: var(--font-weight-medium);
|
||||
/* font-size: 13px; */
|
||||
|
||||
padding: 2px 2px;
|
||||
margin: 0 1.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--surface-base);
|
||||
box-shadow: 0 0 0 0.5px var(--border-weak-base);
|
||||
/* padding: 2px 2px; */
|
||||
/* margin: 0 1.5px; */
|
||||
/* border-radius: 2px; */
|
||||
/* background: var(--surface-base); */
|
||||
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
border-color: var(--border-strong-base);
|
||||
}
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&[data-type="image"] {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -245,6 +249,14 @@
|
||||
|
||||
[data-component="edit-content"] {
|
||||
border-top: 1px solid var(--border-weaker-base);
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="write-content"] {
|
||||
@@ -370,9 +382,9 @@
|
||||
|
||||
&[data-permission="true"] {
|
||||
position: sticky;
|
||||
top: var(--sticky-header-height, 80px);
|
||||
top: calc(2px + var(--sticky-header-height, 40px));
|
||||
bottom: 0px;
|
||||
z-index: 10;
|
||||
z-index: 20;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
@@ -383,6 +395,7 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1.5px;
|
||||
top: -5px;
|
||||
border-radius: 7.5px;
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
Show,
|
||||
Switch,
|
||||
onCleanup,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import {
|
||||
AssistantMessage,
|
||||
@@ -14,6 +25,7 @@ import {
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useCodeComponent } from "../context/code"
|
||||
import { useDialog } from "../context/dialog"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { GenericTool } from "./basic-tool"
|
||||
import { Button } from "./button"
|
||||
@@ -22,6 +34,7 @@ import { Icon } from "./icon"
|
||||
import { Checkbox } from "./checkbox"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
@@ -80,6 +93,47 @@ export type PartComponent = Component<MessagePartProps>
|
||||
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
const TEXT_RENDER_THROTTLE_MS = 100
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function createThrottledValue(getValue: () => string) {
|
||||
const [value, setValue] = createSignal(getValue())
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
createEffect(() => {
|
||||
const next = getValue()
|
||||
const now = Date.now()
|
||||
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
last = now
|
||||
setValue(next)
|
||||
return
|
||||
}
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
last = Date.now()
|
||||
setValue(next)
|
||||
timeout = undefined
|
||||
}, remaining)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
})
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function relativizeProjectPaths(text: string, directory?: string) {
|
||||
if (!text) return ""
|
||||
if (!directory) return text
|
||||
@@ -209,15 +263,21 @@ export function Message(props: MessageProps) {
|
||||
}
|
||||
|
||||
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
return props.parts?.filter((x) => {
|
||||
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
|
||||
})
|
||||
})
|
||||
const emptyParts: PartType[] = []
|
||||
const filteredParts = createMemo(
|
||||
() =>
|
||||
props.parts.filter((x) => {
|
||||
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
|
||||
}),
|
||||
emptyParts,
|
||||
{ equals: same },
|
||||
)
|
||||
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
|
||||
}
|
||||
|
||||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
|
||||
const dialog = useDialog()
|
||||
|
||||
const textPart = createMemo(
|
||||
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
|
||||
)
|
||||
@@ -240,13 +300,26 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
}),
|
||||
)
|
||||
|
||||
const openImagePreview = (url: string, alt?: string) => {
|
||||
dialog.show(() => <ImagePreview src={url} alt={alt} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message">
|
||||
<Show when={attachments().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
<For each={attachments()}>
|
||||
{(file) => (
|
||||
<div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}>
|
||||
<div
|
||||
data-slot="user-message-attachment"
|
||||
data-type={file.mime.startsWith("image/") ? "image" : "file"}
|
||||
data-clickable={file.mime.startsWith("image/") && !!file.url}
|
||||
onClick={() => {
|
||||
if (file.mime.startsWith("image/") && file.url) {
|
||||
openImagePreview(file.url, file.filename)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={file.mime.startsWith("image/") && file.url}
|
||||
fallback={
|
||||
@@ -362,9 +435,22 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
const part = props.part as ToolPart
|
||||
|
||||
const permission = createMemo(() => {
|
||||
const sessionID = props.message.sessionID
|
||||
const permissions = data.store.permission?.[sessionID] ?? []
|
||||
return permissions.find((p) => p.callID === part.callID)
|
||||
const next = data.store.permission?.[props.message.sessionID]?.[0]
|
||||
if (!next) return undefined
|
||||
if (next.callID !== part.callID) return undefined
|
||||
return next
|
||||
})
|
||||
|
||||
const [showPermission, setShowPermission] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const perm = permission()
|
||||
if (perm) {
|
||||
const timeout = setTimeout(() => setShowPermission(true), 50)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
} else {
|
||||
setShowPermission(false)
|
||||
}
|
||||
})
|
||||
|
||||
const [forceOpen, setForceOpen] = createSignal(false)
|
||||
@@ -382,13 +468,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
})
|
||||
}
|
||||
|
||||
const component = createMemo(() => {
|
||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
||||
// @ts-expect-error
|
||||
const metadata = part.state?.metadata ?? {}
|
||||
const input = part.state?.input ?? {}
|
||||
const emptyInput: Record<string, any> = {}
|
||||
const emptyMetadata: Record<string, any> = {}
|
||||
|
||||
return (
|
||||
const input = () => part.state?.input ?? emptyInput
|
||||
// @ts-expect-error
|
||||
const metadata = () => part.state?.metadata ?? emptyMetadata
|
||||
|
||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
||||
|
||||
return (
|
||||
<div data-component="tool-part-wrapper" data-permission={showPermission()}>
|
||||
<Switch>
|
||||
<Match when={part.state.status === "error" && part.state.error}>
|
||||
{(error) => {
|
||||
@@ -417,9 +507,9 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
<Match when={true}>
|
||||
<Dynamic
|
||||
component={render}
|
||||
input={input}
|
||||
input={input()}
|
||||
tool={part.tool}
|
||||
metadata={metadata}
|
||||
metadata={metadata()}
|
||||
// @ts-expect-error
|
||||
output={part.state.output}
|
||||
status={part.state.status}
|
||||
@@ -429,13 +519,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="tool-part-wrapper" data-permission={!!permission()}>
|
||||
<Show when={component()}>{component()}</Show>
|
||||
<Show when={permission()}>
|
||||
<Show when={showPermission() && permission()}>
|
||||
{(perm) => (
|
||||
<div data-component="permission-prompt">
|
||||
<div data-slot="permission-message">{perm().title}</div>
|
||||
@@ -461,11 +545,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const data = useData()
|
||||
const part = props.part as TextPart
|
||||
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
|
||||
const throttledText = createThrottledValue(displayText)
|
||||
|
||||
return (
|
||||
<Show when={displayText()}>
|
||||
<Show when={throttledText()}>
|
||||
<div data-component="text-part">
|
||||
<Markdown text={displayText()} />
|
||||
<Markdown text={throttledText()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
@@ -473,10 +558,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
|
||||
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
const part = props.part as ReasoningPart
|
||||
const text = () => part.text.trim()
|
||||
const throttledText = createThrottledValue(text)
|
||||
|
||||
return (
|
||||
<Show when={part.text.trim()}>
|
||||
<Show when={throttledText()}>
|
||||
<div data-component="reasoning-part">
|
||||
<Markdown text={part.text.trim()} />
|
||||
<Markdown text={throttledText()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
@@ -623,7 +711,7 @@ ToolRegistry.register({
|
||||
const sessionId = childSessionId()
|
||||
if (!sessionId) return undefined
|
||||
const permissions = data.store.permission?.[sessionId] ?? []
|
||||
return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0]
|
||||
return permissions[0]
|
||||
})
|
||||
|
||||
const childToolPart = createMemo(() => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { AssistantMessage, Part as PartType, TextPart, ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
type Permission,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
@@ -60,26 +68,38 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function AssistantMessageItem(props: {
|
||||
message: AssistantMessage
|
||||
summary: string | undefined
|
||||
response: string | undefined
|
||||
lastTextPartId: string | undefined
|
||||
working: boolean
|
||||
responsePartId: string | undefined
|
||||
hideResponsePart: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
|
||||
const lastTextPart = createMemo(() =>
|
||||
msgParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
const emptyParts: PartType[] = []
|
||||
const msgParts = createMemo(() => data.store.part[props.message.id] ?? emptyParts)
|
||||
const lastTextPart = createMemo(() => {
|
||||
const parts = msgParts()
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (part?.type === "text") return part as TextPart
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const filteredParts = createMemo(() => {
|
||||
if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) {
|
||||
return msgParts().filter((p) => p?.id !== lastTextPart()?.id)
|
||||
}
|
||||
return msgParts()
|
||||
const parts = msgParts()
|
||||
if (!props.hideResponsePart) return parts
|
||||
|
||||
const responsePartId = props.responsePartId
|
||||
if (!responsePartId) return parts
|
||||
if (responsePartId !== lastTextPart()?.id) return parts
|
||||
|
||||
return parts.filter((part) => part?.id !== responsePartId)
|
||||
})
|
||||
|
||||
return <Message message={props.message} parts={filteredParts()} />
|
||||
@@ -89,6 +109,7 @@ export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
messageID: string
|
||||
lastUserMessageID?: string
|
||||
stepsExpanded?: boolean
|
||||
onStepsExpandedToggle?: () => void
|
||||
onUserInteracted?: () => void
|
||||
@@ -102,27 +123,76 @@ export function SessionTurn(
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
|
||||
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
allMessages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyPermissions: Permission[] = []
|
||||
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID))
|
||||
const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id)
|
||||
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
|
||||
|
||||
const messageIndex = createMemo(() => {
|
||||
const messages = allMessages()
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
if (!result.found) return -1
|
||||
|
||||
const msg = messages[result.index]
|
||||
if (msg.role !== "user") return -1
|
||||
|
||||
return result.index
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
const index = messageIndex()
|
||||
if (index < 0) return undefined
|
||||
|
||||
const msg = allMessages()[index]
|
||||
if (!msg || msg.role !== "user") return undefined
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
const lastUserMessageID = createMemo(() => {
|
||||
if (props.lastUserMessageID) return props.lastUserMessageID
|
||||
|
||||
const messages = allMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg?.role === "user") return msg.id
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const isLastUserMessage = createMemo(() => props.messageID === lastUserMessageID())
|
||||
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return []
|
||||
return data.store.part[msg.id] ?? []
|
||||
if (!msg) return emptyParts
|
||||
return data.store.part[msg.id] ?? emptyParts
|
||||
})
|
||||
|
||||
const assistantMessages = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return [] as AssistantMessage[]
|
||||
return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[]
|
||||
})
|
||||
const assistantMessages = createMemo(
|
||||
() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyAssistant
|
||||
|
||||
const messages = allMessages()
|
||||
const index = messageIndex()
|
||||
if (index < 0) return emptyAssistant
|
||||
|
||||
const result: AssistantMessage[] = []
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const item = messages[i]
|
||||
if (!item) continue
|
||||
if (item.role === "user") break
|
||||
if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage)
|
||||
}
|
||||
return result
|
||||
},
|
||||
emptyAssistant,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
|
||||
|
||||
@@ -131,7 +201,7 @@ export function SessionTurn(
|
||||
const lastTextPart = createMemo(() => {
|
||||
const msgs = assistantMessages()
|
||||
for (let mi = msgs.length - 1; mi >= 0; mi--) {
|
||||
const msgParts = data.store.part[msgs[mi].id] ?? []
|
||||
const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
|
||||
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
|
||||
const part = msgParts[pi]
|
||||
if (part?.type === "text") return part as TextPart
|
||||
@@ -151,20 +221,25 @@ export function SessionTurn(
|
||||
return false
|
||||
})
|
||||
|
||||
const permissionParts = createMemo(() => {
|
||||
const result: { part: ToolPart; message: AssistantMessage }[] = []
|
||||
const permissions = data.store.permission?.[props.sessionID] ?? []
|
||||
if (!permissions.length) return result
|
||||
const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions)
|
||||
const permissionCount = createMemo(() => permissions().length)
|
||||
const nextPermission = createMemo(() => permissions()[0])
|
||||
|
||||
for (const m of assistantMessages()) {
|
||||
const msgParts = data.store.part[m.id] ?? []
|
||||
for (const p of msgParts) {
|
||||
if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) {
|
||||
result.push({ part: p as ToolPart, message: m })
|
||||
}
|
||||
const permissionParts = createMemo(() => {
|
||||
if (props.stepsExpanded) return emptyPermissionParts
|
||||
|
||||
const next = nextPermission()
|
||||
if (!next) return emptyPermissionParts
|
||||
|
||||
for (const message of assistantMessages()) {
|
||||
const parts = data.store.part[message.id] ?? emptyParts
|
||||
for (const part of parts) {
|
||||
if (part?.type !== "tool") continue
|
||||
const tool = part as ToolPart
|
||||
if (tool.callID === next.callID) return [{ part: tool, message }]
|
||||
}
|
||||
}
|
||||
return result
|
||||
return emptyPermissionParts
|
||||
})
|
||||
|
||||
const shellModePart = createMemo(() => {
|
||||
@@ -174,7 +249,7 @@ export function SessionTurn(
|
||||
const msgs = assistantMessages()
|
||||
if (msgs.length !== 1) return
|
||||
|
||||
const msgParts = data.store.part[msgs[0].id] ?? []
|
||||
const msgParts = data.store.part[msgs[0].id] ?? emptyParts
|
||||
if (msgParts.length !== 1) return
|
||||
|
||||
const assistantPart = msgParts[0]
|
||||
@@ -189,7 +264,7 @@ export function SessionTurn(
|
||||
let currentTask: ToolPart | undefined
|
||||
|
||||
for (let mi = msgs.length - 1; mi >= 0; mi--) {
|
||||
const msgParts = data.store.part[msgs[mi].id] ?? []
|
||||
const msgParts = data.store.part[msgs[mi].id] ?? emptyParts
|
||||
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
|
||||
const part = msgParts[pi]
|
||||
if (!part) continue
|
||||
@@ -216,12 +291,12 @@ export function SessionTurn(
|
||||
: undefined
|
||||
|
||||
if (taskSessionId) {
|
||||
const taskMessages = data.store.message[taskSessionId] ?? []
|
||||
const taskMessages = data.store.message[taskSessionId] ?? emptyMessages
|
||||
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
|
||||
const msg = taskMessages[mi]
|
||||
if (!msg || msg.role !== "assistant") continue
|
||||
|
||||
const msgParts = data.store.part[msg.id] ?? []
|
||||
const msgParts = data.store.part[msg.id] ?? emptyParts
|
||||
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
|
||||
const part = msgParts[pi]
|
||||
if (part) return computeStatusFromPart(part)
|
||||
@@ -232,12 +307,7 @@ export function SessionTurn(
|
||||
return computeStatusFromPart(last)
|
||||
})
|
||||
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
||||
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
@@ -245,12 +315,11 @@ export function SessionTurn(
|
||||
return s
|
||||
})
|
||||
|
||||
const summary = () => message()?.summary?.body
|
||||
const response = () => {
|
||||
const part = lastTextPart()
|
||||
return part?.type === "text" ? (part as TextPart).text : undefined
|
||||
}
|
||||
const hasDiffs = () => message()?.summary?.diffs?.length
|
||||
const summary = createMemo(() => message()?.summary?.body)
|
||||
const response = createMemo(() => lastTextPart()?.text)
|
||||
const responsePartId = createMemo(() => lastTextPart()?.id)
|
||||
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
|
||||
const hideResponsePart = createMemo(() => !working() && !summary() && !!responsePartId())
|
||||
|
||||
function duration() {
|
||||
const msg = message()
|
||||
@@ -328,11 +397,13 @@ export function SessionTurn(
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (permissionParts().length > 0) {
|
||||
createEffect(
|
||||
on(permissionCount, (count, prev) => {
|
||||
if (!count) return
|
||||
if (prev !== undefined && count <= prev) return
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (working() || !isLastUserMessage()) return
|
||||
@@ -342,6 +413,12 @@ export function SessionTurn(
|
||||
if (summary()) return
|
||||
if (store.summaryWaitTimedOut) return
|
||||
|
||||
const completed = lastAssistantMessage()?.time.completed
|
||||
if (completed && Date.now() - completed > 2000) {
|
||||
setStore("summaryWaitTimedOut", true)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setStore("summaryWaitTimedOut", true)
|
||||
}, 6000)
|
||||
@@ -477,10 +554,8 @@ export function SessionTurn(
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageItem
|
||||
message={assistantMessage}
|
||||
summary={summary()}
|
||||
response={response()}
|
||||
lastTextPartId={lastTextPart()?.id}
|
||||
working={working()}
|
||||
responsePartId={responsePartId()}
|
||||
hideResponsePart={hideResponsePart()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[data-component="toast-region"] {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
bottom: 48px;
|
||||
right: 32px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { marked } from "marked"
|
||||
import markedKatex from "marked-katex-extension"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -378,6 +379,9 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
|
||||
name: "Marked",
|
||||
init: () => {
|
||||
return marked.use(
|
||||
markedKatex({
|
||||
throwOnError: false,
|
||||
}),
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] })
|
||||
|
||||
@@ -19,38 +19,88 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let isMouseDown = false
|
||||
let cleanupListeners: (() => void) | undefined
|
||||
let scheduledScroll = false
|
||||
let scheduledForce = false
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || store.userScrolled || !options.working()) return
|
||||
function distanceFromBottom() {
|
||||
if (!scrollRef) return 0
|
||||
return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop
|
||||
}
|
||||
|
||||
function startAutoScroll() {
|
||||
isAutoScrolling = true
|
||||
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
|
||||
autoScrollTimeout = setTimeout(() => {
|
||||
isAutoScrolling = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function scrollToBottomNow() {
|
||||
if (!scrollRef || store.userScrolled || !options.working()) return
|
||||
|
||||
const distance = distanceFromBottom()
|
||||
if (distance < 2) return
|
||||
|
||||
const behavior = distance > 96 ? "auto" : "smooth"
|
||||
startAutoScroll()
|
||||
scrollRef.scrollTo({
|
||||
top: scrollRef.scrollHeight,
|
||||
behavior: "smooth",
|
||||
behavior,
|
||||
})
|
||||
}
|
||||
|
||||
function forceScrollToBottom() {
|
||||
function forceScrollToBottomNow() {
|
||||
if (!scrollRef) return
|
||||
|
||||
setStore("userScrolled", false)
|
||||
isAutoScrolling = true
|
||||
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
|
||||
autoScrollTimeout = setTimeout(() => {
|
||||
isAutoScrolling = false
|
||||
}, 1000)
|
||||
if (store.userScrolled) setStore("userScrolled", false)
|
||||
|
||||
const distance = distanceFromBottom()
|
||||
if (distance < 2) return
|
||||
|
||||
startAutoScroll()
|
||||
scrollRef.scrollTo({
|
||||
top: scrollRef.scrollHeight,
|
||||
behavior: "smooth",
|
||||
behavior: "auto",
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleScrollToBottom(force = false) {
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
if (force) {
|
||||
forceScrollToBottomNow()
|
||||
return
|
||||
}
|
||||
scrollToBottomNow()
|
||||
return
|
||||
}
|
||||
|
||||
if (force) scheduledForce = true
|
||||
if (scheduledScroll) return
|
||||
|
||||
scheduledScroll = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduledScroll = false
|
||||
|
||||
const shouldForce = scheduledForce
|
||||
scheduledForce = false
|
||||
|
||||
if (shouldForce) {
|
||||
forceScrollToBottomNow()
|
||||
return
|
||||
}
|
||||
|
||||
scrollToBottomNow()
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
scheduleScrollToBottom(false)
|
||||
}
|
||||
|
||||
function forceScrollToBottom() {
|
||||
scheduleScrollToBottom(true)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef) return
|
||||
|
||||
@@ -132,6 +182,20 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure pinned-to-bottom stays pinned during heavy DOM updates
|
||||
createEffect(() => {
|
||||
const el = store.contentRef
|
||||
if (!el) return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (store.userScrolled) return
|
||||
if (!options.working()) return
|
||||
scheduleScrollToBottom(false)
|
||||
})
|
||||
observer.observe(el, { childList: true, subtree: true, characterData: true })
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
// Handle content resize
|
||||
createResizeObserver(
|
||||
() => store.contentRef,
|
||||
|
||||
@@ -51,9 +51,17 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
)
|
||||
})
|
||||
|
||||
function initialActive() {
|
||||
if (props.current) return props.key(props.current)
|
||||
|
||||
const items = flat()
|
||||
if (items.length === 0) return ""
|
||||
return props.key(items[0])
|
||||
}
|
||||
|
||||
const list = createList({
|
||||
items: () => flat().map(props.key),
|
||||
initialActive: props.current ? props.key(props.current) : props.key(flat()[0]),
|
||||
initialActive: initialActive(),
|
||||
loop: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "./theme.css" layer(theme);
|
||||
|
||||
@import "./base.css" layer(base);
|
||||
@import "katex/dist/katex.min.css" layer(base);
|
||||
|
||||
@import "../components/accordion.css" layer(components);
|
||||
@import "../components/avatar.css" layer(components);
|
||||
@@ -21,6 +22,7 @@
|
||||
@import "../components/provider-icon.css" layer(components);
|
||||
@import "../components/icon.css" layer(components);
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/image-preview.css" layer(components);
|
||||
@import "../components/text-field.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
@import "../components/logo.css" layer(components);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -106,7 +106,8 @@ You can configure TUI-specific settings through the `tui` option.
|
||||
"scroll_speed": 3,
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"diff_style": "auto"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -115,6 +116,7 @@ Available options:
|
||||
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
|
||||
- `scroll_speed` - Custom scroll speed multiplier (default: `1`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
|
||||
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
|
||||
|
||||
[Learn more about using the TUI here](/docs/tui).
|
||||
|
||||
@@ -178,6 +180,25 @@ You can configure the providers and models you want to use in your OpenCode conf
|
||||
|
||||
The `small_model` option configures a separate model for lightweight tasks like title generation. By default, OpenCode tries to use a cheaper model if one is available from your provider, otherwise it falls back to your main model.
|
||||
|
||||
Provider options can include `timeout` and `setCacheKey`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"options": {
|
||||
"timeout": 600000,
|
||||
"setCacheKey": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `timeout` - Request timeout in milliseconds (default: 300000). Set to `false` to disable.
|
||||
- `setCacheKey` - Ensure a cache key is always set for designated provider.
|
||||
|
||||
You can also configure [local models](/docs/models#local). [Learn more](/docs/models).
|
||||
|
||||
---
|
||||
@@ -363,6 +384,42 @@ For example, to ensure that the `edit` and `bash` tools require user approval:
|
||||
|
||||
---
|
||||
|
||||
### Compaction
|
||||
|
||||
You can control context compaction behavior through the `compaction` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"prune": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `auto` - Automatically compact the session when context is full (default: `true`).
|
||||
- `prune` - Remove old tool outputs to save tokens (default: `true`).
|
||||
|
||||
---
|
||||
|
||||
### Watcher
|
||||
|
||||
You can configure file watcher ignore patterns through the `watcher` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"watcher": {
|
||||
"ignore": ["node_modules/**", "dist/**", ".git/**"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Patterns follow glob syntax. Use this to exclude noisy directories from file watching.
|
||||
|
||||
---
|
||||
|
||||
### MCP servers
|
||||
|
||||
You can configure MCP servers you want to use through the `mcp` option.
|
||||
@@ -455,6 +512,23 @@ If a provider appears in both `enabled_providers` and `disabled_providers`, the
|
||||
|
||||
---
|
||||
|
||||
### Experimental
|
||||
|
||||
The `experimental` key contains options that are under active development.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {}
|
||||
}
|
||||
```
|
||||
|
||||
:::caution
|
||||
Experimental options are not stable. They may change or be removed without notice.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Variables
|
||||
|
||||
You can use variable substitution in your config files to reference environment variables and file contents.
|
||||
|
||||
@@ -32,9 +32,11 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
||||
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
|
||||
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
||||
| [opencode-notificator](https://github.com/panta/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
|
||||
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
|
||||
| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
|
||||
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
|
||||
| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
|
||||
| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -354,12 +354,21 @@ If you have a large number of MCP servers you may want to only enable them per a
|
||||
|
||||
#### Glob patterns
|
||||
|
||||
The glob pattern uses simple regex globbing patterns.
|
||||
The glob pattern uses simple regex globbing patterns:
|
||||
|
||||
- `*` matches zero or more of any character
|
||||
- `*` matches zero or more of any character (e.g., `"my-mcp*"` matches `my-mcp_search`, `my-mcp_list`, etc.)
|
||||
- `?` matches exactly one character
|
||||
- All other characters match literally
|
||||
|
||||
:::note
|
||||
MCP server tools are registered with server name as prefix, so to diable all tools for a server simply use:
|
||||
|
||||
```
|
||||
"mymcpservername_*": false
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -47,7 +47,7 @@ Browse available plugins in the [ecosystem](/docs/ecosystem#plugins).
|
||||
|
||||
**npm plugins** are installed automatically using Bun at startup. Packages and their dependencies are cached in `~/.cache/opencode/node_modules/`.
|
||||
|
||||
**Local plugins** are loaded directly from the plugin directory. Dependencies are not installed automatically. If your local plugin requires external packages, publish it to npm instead and add it to your config.
|
||||
**Local plugins** are loaded directly from the plugin directory. To use external packages, you must create a `package.json` within your config directory (see [Dependencies](#dependencies)), or publish the plugin to npm and [add it to your config](/docs/config#plugins).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,6 +71,36 @@ functions. Each function receives a context object and returns a hooks object.
|
||||
|
||||
---
|
||||
|
||||
### Dependencies
|
||||
|
||||
Local plugins and custom tools can use external npm packages. Add a `package.json` to your config directory with the dependencies you need.
|
||||
|
||||
```json title=".opencode/package.json"
|
||||
{
|
||||
"dependencies": {
|
||||
"shescape": "^2.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them.
|
||||
|
||||
```ts title=".opencode/plugin/my-plugin.ts"
|
||||
import { escape } from "shescape"
|
||||
|
||||
export const MyPlugin = async (ctx) => {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
if (input.tool === "bash") {
|
||||
output.args.command = escape(output.args.command)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Basic structure
|
||||
|
||||
```js title=".opencode/plugin/example.js"
|
||||
|
||||
@@ -869,6 +869,33 @@ To use Kimi K2 from Moonshot AI:
|
||||
|
||||
---
|
||||
|
||||
### MiniMax
|
||||
|
||||
1. Head over to the [MiniMax API Console](https://platform.minimax.io/login), create an account, and generate an API key.
|
||||
|
||||
2. Run the `/connect` command and search for **MiniMax**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your MiniMax API key.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
4. Run the `/models` command to select a model like _M2.1_.
|
||||
|
||||
```txt
|
||||
/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Nebius Token Factory
|
||||
|
||||
1. Head over to the [Nebius Token Factory console](https://tokenfactory.nebius.com/), create an account, and click **Add Key**.
|
||||
@@ -1277,7 +1304,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
|
||||
└ enter
|
||||
```
|
||||
|
||||
4. Run the `/models` command to select a model like _GLM-4.5_.
|
||||
4. Run the `/models` command to select a model like _GLM-4.7_.
|
||||
|
||||
```txt
|
||||
/models
|
||||
|
||||
@@ -62,30 +62,32 @@ You are charged per request and you can add credits to your account.
|
||||
|
||||
You can also access our models through the following API endpoints.
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ----------------- | ----------------- | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| GLM 4.7 | glm-4.7-free | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Grok Code Fast 1 | grok-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.1 | minimax-m2.1-free | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.7 | glm-4.7-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Grok Code Fast 1 | grok-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
The [model id](/docs/config/#models) in your OpenCode config
|
||||
uses the format `opencode/<model-id>`. For example, for GPT 5.1 Codex, you would
|
||||
@@ -111,6 +113,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| --------------------------------- | ------ | ------ | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| Grok Code Fast 1 | Free | Free | Free | - |
|
||||
| MiniMax M2.1 | Free | Free | Free | - |
|
||||
| GLM 4.7 | Free | Free | Free | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
@@ -131,6 +134,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
@@ -145,6 +149,7 @@ The free models:
|
||||
|
||||
- Grok Code Fast 1 is currently free on OpenCode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code.
|
||||
- GLM 4.7 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiniMax M2.1 is currently free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
|
||||
<a href={email}>Contact us</a> if you have any questions.
|
||||
@@ -157,6 +162,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
|
||||
- Grok Code Fast 1: During its free period, collected data may be used to improve Grok Code.
|
||||
- GLM 4.7: During its free period, collected data may be used to improve the model.
|
||||
- MiniMax M2.1: During its free period, collected data may be used to improve the model.
|
||||
- Big Pickle: During its free period, collected data may be used to improve the model.
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { createOpencode } from "@opencode-ai/sdk"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
export const team = [
|
||||
"actions-user",
|
||||
"opencode",
|
||||
"rekram1-node",
|
||||
"thdxr",
|
||||
"kommander",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"adamdotdevin",
|
||||
"iamdavidhill",
|
||||
"opencode-agent[bot]",
|
||||
]
|
||||
|
||||
export async function getLatestRelease() {
|
||||
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
return res.json()
|
||||
})
|
||||
.then((data: any) => data.tag_name.replace(/^v/, ""))
|
||||
}
|
||||
|
||||
type Commit = {
|
||||
hash: string
|
||||
author: string | null
|
||||
message: string
|
||||
areas: Set<string>
|
||||
}
|
||||
|
||||
export async function getCommits(from: string, to: string): Promise<Commit[]> {
|
||||
const fromRef = from.startsWith("v") ? from : `v${from}`
|
||||
const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`
|
||||
|
||||
// Get commit data with GitHub usernames from the API
|
||||
const compare =
|
||||
await $`gh api "/repos/sst/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
||||
|
||||
const commitData = new Map<string, { login: string | null; message: string }>()
|
||||
for (const line of compare.split("\n").filter(Boolean)) {
|
||||
const data = JSON.parse(line) as { sha: string; login: string | null; message: string }
|
||||
commitData.set(data.sha, { login: data.login, message: data.message.split("\n")[0] ?? "" })
|
||||
}
|
||||
|
||||
// Get commits that touch the relevant packages
|
||||
const log =
|
||||
await $`git log ${fromRef}..${toRef} --oneline --format="%H" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
||||
const hashes = log.split("\n").filter(Boolean)
|
||||
|
||||
const commits: Commit[] = []
|
||||
for (const hash of hashes) {
|
||||
const data = commitData.get(hash)
|
||||
if (!data) continue
|
||||
|
||||
const message = data.message
|
||||
if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
|
||||
const files = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
||||
const areas = new Set<string>()
|
||||
|
||||
for (const file of files.split("\n").filter(Boolean)) {
|
||||
if (file.startsWith("packages/opencode/src/cli/cmd/tui/")) areas.add("tui")
|
||||
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
||||
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
||||
else if (file.startsWith("packages/desktop/")) areas.add("app")
|
||||
else if (file.startsWith("packages/app/")) areas.add("app")
|
||||
else if (file.startsWith("packages/sdk/")) areas.add("sdk")
|
||||
else if (file.startsWith("packages/plugin/")) areas.add("plugin")
|
||||
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
|
||||
else if (file.startsWith("sdks/vscode/")) areas.add("extensions/vscode")
|
||||
else if (file.startsWith("github/")) areas.add("github")
|
||||
}
|
||||
|
||||
if (areas.size === 0) continue
|
||||
|
||||
commits.push({
|
||||
hash: hash.slice(0, 7),
|
||||
author: data.login,
|
||||
message,
|
||||
areas,
|
||||
})
|
||||
}
|
||||
|
||||
return commits
|
||||
}
|
||||
|
||||
const sections = {
|
||||
core: "Core",
|
||||
tui: "TUI",
|
||||
app: "Desktop",
|
||||
tauri: "Desktop",
|
||||
sdk: "SDK",
|
||||
plugin: "SDK",
|
||||
"extensions/zed": "Extensions",
|
||||
"extensions/vscode": "Extensions",
|
||||
github: "Extensions",
|
||||
} as const
|
||||
|
||||
function getSection(areas: Set<string>): string {
|
||||
// Priority order for multi-area commits
|
||||
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
|
||||
for (const area of priority) {
|
||||
if (areas.has(area)) return sections[area as keyof typeof sections]
|
||||
}
|
||||
return "Core"
|
||||
}
|
||||
|
||||
async function summarizeCommit(
|
||||
opencode: Awaited<ReturnType<typeof createOpencode>>,
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<string> {
|
||||
console.log("summarizing commit:", message)
|
||||
const result = await opencode.client.session
|
||||
.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "opencode", modelID: "claude-sonnet-4-5" },
|
||||
tools: {
|
||||
"*": false,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:".
|
||||
|
||||
Commit: ${message}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
.then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message)
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function generateChangelog(commits: Commit[], opencode: Awaited<ReturnType<typeof createOpencode>>) {
|
||||
const session = await opencode.client.session.create()
|
||||
|
||||
const grouped = new Map<string, string[]>()
|
||||
|
||||
for (const commit of commits) {
|
||||
const section = getSection(commit.areas)
|
||||
const summary = await summarizeCommit(opencode, session.data!.id, commit.message)
|
||||
const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||
const entry = `- ${summary}${attribution}`
|
||||
|
||||
if (!grouped.has(section)) grouped.set(section, [])
|
||||
grouped.get(section)!.push(entry)
|
||||
}
|
||||
|
||||
const sectionOrder = ["Core", "TUI", "Desktop", "SDK", "Extensions"]
|
||||
const lines: string[] = []
|
||||
for (const section of sectionOrder) {
|
||||
const entries = grouped.get(section)
|
||||
if (!entries || entries.length === 0) continue
|
||||
lines.push(`## ${section}`)
|
||||
lines.push(...entries)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
export async function getContributors(from: string, to: string) {
|
||||
const fromRef = from.startsWith("v") ? from : `v${from}`
|
||||
const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`
|
||||
const compare =
|
||||
await $`gh api "/repos/sst/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text()
|
||||
const contributors = new Map<string, string[]>()
|
||||
|
||||
for (const line of compare.split("\n").filter(Boolean)) {
|
||||
const { login, message } = JSON.parse(line) as { login: string | null; message: string }
|
||||
const title = message.split("\n")[0] ?? ""
|
||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
|
||||
if (login && !team.includes(login)) {
|
||||
if (!contributors.has(login)) contributors.set(login, [])
|
||||
contributors.get(login)?.push(title)
|
||||
}
|
||||
}
|
||||
|
||||
return contributors
|
||||
}
|
||||
|
||||
export async function buildNotes(from: string, to: string) {
|
||||
const commits = await getCommits(from, to)
|
||||
|
||||
if (commits.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
console.log("generating changelog since " + from)
|
||||
|
||||
const opencode = await createOpencode({ port: 5044 })
|
||||
const notes: string[] = []
|
||||
|
||||
try {
|
||||
const lines = await generateChangelog(commits, opencode)
|
||||
notes.push(...lines)
|
||||
console.log("---- Generated Changelog ----")
|
||||
console.log(notes.join("\n"))
|
||||
console.log("-----------------------------")
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "TimeoutError") {
|
||||
console.log("Changelog generation timed out, using raw commits")
|
||||
for (const commit of commits) {
|
||||
const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||
notes.push(`- ${commit.message}${attribution}`)
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
opencode.server.close()
|
||||
}
|
||||
|
||||
const contributors = await getContributors(from, to)
|
||||
|
||||
if (contributors.size > 0) {
|
||||
notes.push("")
|
||||
notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`)
|
||||
for (const [username, userCommits] of contributors) {
|
||||
notes.push(`- @${username}:`)
|
||||
for (const c of userCommits) {
|
||||
notes.push(` - ${c}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
// CLI entrypoint
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
from: { type: "string", short: "f" },
|
||||
to: { type: "string", short: "t", default: "HEAD" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
})
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: bun script/changelog.ts [options]
|
||||
|
||||
Options:
|
||||
-f, --from <version> Starting version (default: latest GitHub release)
|
||||
-t, --to <ref> Ending ref (default: HEAD)
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
bun script/changelog.ts # Latest release to HEAD
|
||||
bun script/changelog.ts --from 1.0.200 # v1.0.200 to HEAD
|
||||
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const to = values.to!
|
||||
const from = values.from ?? (await getLatestRelease())
|
||||
|
||||
console.log(`Generating changelog: v${from} -> ${to}\n`)
|
||||
|
||||
const notes = await buildNotes(from, to)
|
||||
console.log("\n=== Final Notes ===")
|
||||
console.log(notes.join("\n"))
|
||||
}
|
||||
+4
-150
@@ -1,162 +1,16 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { createOpencode } from "@opencode-ai/sdk"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { buildNotes, getLatestRelease } from "./changelog"
|
||||
|
||||
const notes = [] as string[]
|
||||
let notes: string[] = []
|
||||
|
||||
console.log("=== publishing ===\n")
|
||||
|
||||
if (!Script.preview) {
|
||||
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
return res.json()
|
||||
})
|
||||
.then((data: any) => data.tag_name.replace(/^v/, ""))
|
||||
|
||||
const log =
|
||||
await $`git log v${previous}..HEAD --oneline --format="%h %s" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app`.text()
|
||||
|
||||
const commits = log.split("\n").filter((line) => line && !line.match(/^\w+ (ignore:|test:|chore:|ci:)/i))
|
||||
|
||||
const team = [
|
||||
"actions-user",
|
||||
"opencode",
|
||||
"rekram1-node",
|
||||
"thdxr",
|
||||
"kommander",
|
||||
"jayair",
|
||||
"fwang",
|
||||
"adamdotdevin",
|
||||
"iamdavidhill",
|
||||
"opencode-agent[bot]",
|
||||
]
|
||||
|
||||
async function generateChangelog() {
|
||||
const opencode = await createOpencode()
|
||||
const session = await opencode.client.session.create()
|
||||
console.log("generating changelog since " + previous)
|
||||
|
||||
const raw = await opencode.client.session
|
||||
.prompt({
|
||||
path: {
|
||||
id: session.data!.id,
|
||||
},
|
||||
body: {
|
||||
model: {
|
||||
providerID: "opencode",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `
|
||||
Analyze these commits and generate a changelog of all notable user facing changes, grouped by area.
|
||||
|
||||
Each commit below includes:
|
||||
- [author: username] showing the GitHub username of the commit author
|
||||
- [areas: ...] showing which areas of the codebase were modified
|
||||
|
||||
Commits between ${previous} and HEAD:
|
||||
${commits.join("\n")}
|
||||
|
||||
Group the changes into these categories based on the [areas: ...] tags (omit any category with no changes):
|
||||
- **TUI**: Changes to "opencode" area (the terminal/CLI interface)
|
||||
- **Desktop**: Changes to "app" or "tauri" areas (the desktop application)
|
||||
- **SDK**: Changes to "sdk" or "plugin" areas (the SDK and plugin system)
|
||||
- **Extensions**: Changes to "extensions/zed", "extensions/vscode", or "github" areas (editor extensions and GitHub Action)
|
||||
- **Other**: Any user-facing changes that don't fit the above categories
|
||||
|
||||
Excluded areas (omit these entirely unless they contain user-facing changes like refactors that may affect behavior):
|
||||
- "nix", "infra", "script" - CI/build infrastructure
|
||||
- "ui", "docs", "web", "console", "enterprise", "function", "util", "identity", "slack" - internal packages
|
||||
|
||||
Rules:
|
||||
- Use the [areas: ...] tags to determine the correct category. If a commit touches multiple areas, put it in the most relevant user-facing category.
|
||||
- ONLY include commits that have user-facing impact. Omit purely internal changes (CI, build scripts, internal tooling).
|
||||
- However, DO include refactors that touch user-facing code - refactors can introduce bugs or change behavior.
|
||||
- Do NOT make general statements about "improvements", be very specific about what was changed.
|
||||
- For commits that are already well-written and descriptive, avoid rewording them. Simply capitalize the first letter, fix any misspellings, and ensure proper English grammar.
|
||||
- DO NOT read any other commits than the ones listed above (THIS IS IMPORTANT TO AVOID DUPLICATING THINGS IN OUR CHANGELOG).
|
||||
- If a commit was made and then reverted do not include it in the changelog. If the commits only include a revert but not the original commit, then include the revert in the changelog.
|
||||
- Omit categories that have no changes.
|
||||
- For community contributors: if the [author: username] is NOT in the team list, add (@username) at the end of the changelog entry. This is REQUIRED for all non-team contributors.
|
||||
- The team members are: ${team.join(", ")}. Do NOT add @ mentions for team members.
|
||||
|
||||
IMPORTANT: ONLY return the grouped changelog, do not include any other information. Do not include a preamble like "Based on my analysis..." or "Here is the changelog..."
|
||||
|
||||
<example>
|
||||
## TUI
|
||||
- Added experimental support for the Ty language server (@OpeOginni)
|
||||
- Added /fork slash command for keyboard-friendly session forking (@ariane-emory)
|
||||
- Increased retry attempts for failed requests
|
||||
- Fixed model validation before executing slash commands (@devxoul)
|
||||
|
||||
## Desktop
|
||||
- Added shell mode support
|
||||
- Fixed prompt history navigation and optimistic prompt duplication
|
||||
- Disabled pinch-to-zoom on Linux (@Brendonovich)
|
||||
|
||||
## Extensions
|
||||
- Added OIDC_BASE_URL support for custom GitHub App installations (@elithrar)
|
||||
</example>
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.then((x) => x.data?.parts?.find((y) => y.type === "text")?.text)
|
||||
opencode.server.close()
|
||||
return raw
|
||||
}
|
||||
|
||||
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 120_000))
|
||||
const raw = await Promise.race([generateChangelog(), timeout])
|
||||
|
||||
if (raw) {
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.startsWith("- ")) {
|
||||
notes.push(line)
|
||||
}
|
||||
}
|
||||
console.log("---- Generated Changelog ----")
|
||||
console.log(notes.join("\n"))
|
||||
console.log("-----------------------------")
|
||||
} else {
|
||||
console.log("Changelog generation timed out, using raw commits")
|
||||
for (const commit of commits) {
|
||||
const message = commit.replace(/^\w+ /, "")
|
||||
notes.push(`- ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const compare =
|
||||
await $`gh api "/repos/sst/opencode/compare/v${previous}...HEAD" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text()
|
||||
const contributors = new Map<string, string[]>()
|
||||
|
||||
for (const line of compare.split("\n").filter(Boolean)) {
|
||||
const { login, message } = JSON.parse(line) as { login: string | null; message: string }
|
||||
const title = message.split("\n")[0] ?? ""
|
||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
|
||||
if (login && !team.includes(login)) {
|
||||
if (!contributors.has(login)) contributors.set(login, [])
|
||||
contributors.get(login)?.push(title)
|
||||
}
|
||||
}
|
||||
|
||||
if (contributors.size > 0) {
|
||||
notes.push("")
|
||||
notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`)
|
||||
for (const [username, userCommits] of contributors) {
|
||||
notes.push(`- @${username}:`)
|
||||
for (const commit of userCommits) {
|
||||
notes.push(` - ${commit}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const previous = await getLatestRelease()
|
||||
notes = await buildNotes(previous, "HEAD")
|
||||
}
|
||||
|
||||
const pkgjsons = await Array.fromAsync(
|
||||
|
||||
+3
-1
@@ -107,7 +107,9 @@ async function main() {
|
||||
|
||||
console.log(`📬 Creating pull request...`)
|
||||
const prUrl =
|
||||
await $`GH_TOKEN=${token} gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text()
|
||||
await $`gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`
|
||||
.env({ GH_TOKEN: token })
|
||||
.text()
|
||||
|
||||
console.log(`✅ Pull request created: ${prUrl}`)
|
||||
console.log(`🎉 Done!`)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.207",
|
||||
"version": "1.0.209",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user