Merge branch 'dev' into agent-loop
Some checks are pending
format / format (push) Waiting to run
test / test (push) Waiting to run

This commit is contained in:
Dax Raad 2025-11-16 21:01:30 -05:00
commit 857c083e35
48 changed files with 1085 additions and 254 deletions

View file

@ -4,7 +4,7 @@ on:
push:
branches:
- dev
- windows
- fix-build
- v0
concurrency: ${{ github.workflow }}-${{ github.ref }}

View file

@ -30,6 +30,7 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use --pin -g ubi:sst/opencode # Any OS
```
> [!TIP]
@ -58,6 +59,10 @@ For more info on how to configure OpenCode [**head over to our docs**](https://o
If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway.
### FAQ
#### How is this different than Claude Code?

View file

@ -140,3 +140,5 @@
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |

View file

@ -40,7 +40,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -67,7 +67,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@ -91,7 +91,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -115,7 +115,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -155,7 +155,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@ -171,7 +171,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.65",
"version": "1.0.68",
"bin": {
"opencode": "./bin/opencode",
},
@ -189,8 +189,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.42",
"@opentui/solid": "0.1.42",
"@opentui/core": "0.1.45",
"@opentui/solid": "0.1.45",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@ -249,7 +249,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@ -269,7 +269,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.65",
"version": "1.0.68",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@ -280,7 +280,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -293,7 +293,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@ -323,7 +323,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -966,21 +966,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.42", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.42", "@opentui/core-darwin-x64": "0.1.42", "@opentui/core-linux-arm64": "0.1.42", "@opentui/core-linux-x64": "0.1.42", "@opentui/core-win32-arm64": "0.1.42", "@opentui/core-win32-x64": "0.1.42", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oV2xHBB2HaNiGvaV6R0C8GmniNJSsLKop4APq4FrLyCYberc6vZcATSHcA5YT9krdvHbBDOOn9RI2oaVJYRbUQ=="],
"@opentui/core": ["@opentui/core@0.1.45", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.45", "@opentui/core-darwin-x64": "0.1.45", "@opentui/core-linux-arm64": "0.1.45", "@opentui/core-linux-x64": "0.1.45", "@opentui/core-win32-arm64": "0.1.45", "@opentui/core-win32-x64": "0.1.45", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iGzU1rZNfBfnw9TEHppvt1B9gtOC/jl0KU3fezCoXoMRCcy3STYLhZbEsYM6cX6AnRK85DqTzC6E6eWWUwGMlA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.42", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sk5b/kh/y8HUJ7stGA5ydkajJX/z2OiGqSm+wn6XIoqdDavxQaFoQOt1PCuCqaxqZWJcXZ6OmISDVagZPUsPuw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.45", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7pzgeYTkR88bufNY4ZnUBnqjsmR9wjx/wH81YyAtc8Hnp/6l+tU+1DHEJEseIArJKRIETwMMNs0W4oxSyQEAJg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.42", "", { "os": "darwin", "cpu": "x64" }, "sha512-b0FKTw+t/wlJg4u+wTurWzbQe47gExkjguaGSUua0m0vybrkkvbUvmrADr+yivCjxcPAhSZ3lOOVU3uZuWsNqw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.45", "", { "os": "darwin", "cpu": "x64" }, "sha512-N1dk4T57/qZorpshJqJaObp9PT3WP8F7aRX5bSjcPSeHDZkfkWYnfIhgc5tRzCu1FqYx7M3HEUlI21YKuThCyQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.42", "", { "os": "linux", "cpu": "arm64" }, "sha512-Vy8BrjJpv2f56JAsYmv4PkC+2HsCv8Gh0ErrlIJQ8L4h29oWabS44m0uxFdvjuTDgKpCJzOScsxsy1VGzSd9rw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.45", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rm1DeH80wGHgXZkIMzP9KRrbZOMJJZxG1f2CO7NEt5/sE2buPQsLF4HcqwycIFJvBdSnpTovzm+hK1sT08zr4w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.42", "", { "os": "linux", "cpu": "x64" }, "sha512-cO+13E1HIAPUdV/DRdKotHFAxsLc+ipbbFKGAuu/msfvywCnnNs86w22yeMg0cEqx7aBocWWT1XfJEHDJLFOqw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.45", "", { "os": "linux", "cpu": "x64" }, "sha512-0Olnj36Sqb1ukCngvf0SPBmZHtT2p9SMYWj6EZgx6KkHTNbDs00/sA8mctViX1B/AJW5v0+E3FT1SgyDwvBDbw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.42", "", { "os": "win32", "cpu": "arm64" }, "sha512-xpLhODjOWh7gMOSrKIldb4v6hR0TGyz6kjckDKwcjUv3LGbLJuSly+3O/zuWWS60dt56G1X4A0OyjWwiGZjc0g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.45", "", { "os": "win32", "cpu": "arm64" }, "sha512-cwo4nSiqSeesleNaWD/PqbWgiXesd6NOwbH6dkDfW5CNenRRm9kNtm+Z5EXYfIf4wcCwyeqOlQZXZgMVNL3cIg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.42", "", { "os": "win32", "cpu": "x64" }, "sha512-pao5XdAln93WWPdsTF+V+HccZ5d1ijSmv0OoBbkjkVbP+tiN41yxNqg/7jzW9IiAakYsvmpKV+3ixi/dlBEvOQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.45", "", { "os": "win32", "cpu": "x64" }, "sha512-11ZiCoAya94oNmyav3OB5FdeMoR0bKlQLMK/ObxwWSJW5N7Ccw+2BNz1VQZNSvUQfJeWWXRaVz7XesOhFu/tLg=="],
"@opentui/solid": ["@opentui/solid@0.1.42", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.42", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-4TNlEtatZ4n9TcKPWSF/EoaPaLmZuFVJ4hHh9wRggNaGrmDlmJ+9N/8oEKXETt+oRDX/1CdowAaTOVfaqb1t6g=="],
"@opentui/solid": ["@opentui/solid@0.1.45", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.45", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Mtn+Kz9osYx/3PMJUMYVHjQch1r4DgEi8V3Es2/vD7ha/kMg2PMTDg+J5RWOheUkXCyfm3G9imxlOIG+aEcGuA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@ -1684,15 +1684,15 @@
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"bun-webgpu": ["bun-webgpu@0.1.3", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.3", "bun-webgpu-darwin-x64": "^0.1.3", "bun-webgpu-linux-x64": "^0.1.3", "bun-webgpu-win32-x64": "^0.1.3" } }, "sha512-IXFxaIi4rgsEEpl9n/QVDm5RajCK/0FcOXZeMb52YRjoiAR1YVYK5hLrXT8cm+KDi6LVahA9GJFqOR4yiloVCw=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KkNQ9gT7dxGDndQaHTTHss9miukqpczML3pO2nZJoT/nITwe9lw3ZGFJMujkW41BUQ1mDYKFgo5nBGf9xYHPAg=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TODWnMUbCoqD/wqzlB3oGOBIUWIFly0lqMeBFz/MBV+ndjbnkNrP9huaZJCTkCVEPKGtd1FCM3ExZUtBbnGziA=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-X+PjwJUWenUmdQBP8EtdItMyieQ6Nlpn+BH518oaouDiSnWj5+b0Y7DNDZJq7Ezom4EaxmqL/uGYZK3aCQ7CXg=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-lVHORoVu1G61XVM8CRRqUsqr6w8kMlpuSpbPGpKUpmvrsoay6ymXAhT5lRPKyrGNamHUQTknmWdI59aRDCfLtQ=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zMLs2YIGB+/jxrYFXaFhVKX/GBt05UTF45lc9srcHc9JXGjEj+12CIo1CHLTAWatXMTqt0Jsu6ukWEoWVT/ayA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vlspsFffctJlBnFfs2lW3QgDD6LyFu8VT18ryID7Qka5poTj0clGVRxz7DFRi7yva3GovEGw/82z/WVc5US8Pw=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],

View file

@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.65"
"version": "1.0.68"
},
"dependencies": {
"@ibm/plex": "6.4.1",

View file

@ -1,88 +1,111 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
/* Empty state */
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
thead {
border-bottom: 1px solid var(--color-border);
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
&[data-slot="usage-model"] {
font-family: var(--font-sans);
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-cost"] {
color: var(--color-text);
font-weight: 500;
}
}
&[data-slot="usage-date"] {
color: var(--color-text);
}
tbody tr:last-child td {
border-bottom: none;
}
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
&[data-slot="usage-cost"] {
color: var(--color-text);
}
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
/* Mobile responsive */
@media (max-width: 40rem) {
[data-slot="usage-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
}
}

View file

@ -1,91 +1,59 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import styles from "./usage-section.module.css"
import "./usage-section.module.css"
import { createStore } from "solid-js/store"
const getUsageInfo = query(async (workspaceID: string) => {
const PAGE_SIZE = 50
async function getUsageInfo(workspaceID: string, page: number) {
"use server"
return withActor(async () => {
return await Billing.usages()
return await Billing.usages(page, PAGE_SIZE)
}, workspaceID)
}, "usage.list")
}
const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const usage = createAsync(() => getUsageInfo(params.id!))
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
// DUMMY DATA FOR TESTING
// const usage = () => [
// {
// timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 1247,
// outputTokens: 423,
// cost: 125400000, // $1.254
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
// model: "claude-3-haiku-20240307",
// inputTokens: 892,
// outputTokens: 156,
// cost: 23500000, // $0.235
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 2134,
// outputTokens: 687,
// cost: 234700000, // $2.347
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
// model: "gpt-4o-mini",
// inputTokens: 567,
// outputTokens: 234,
// cost: 8900000, // $0.089
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
// model: "claude-3-opus-20240229",
// inputTokens: 1893,
// outputTokens: 945,
// cost: 445600000, // $4.456
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
// model: "gpt-4o",
// inputTokens: 1456,
// outputTokens: 532,
// cost: 156800000, // $1.568
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
// model: "claude-3-haiku-20240307",
// inputTokens: 634,
// outputTokens: 89,
// cost: 12300000, // $0.123
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 3245,
// outputTokens: 1123,
// cost: 387200000, // $3.872
// },
// ]
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
page: store.page - 1,
usage,
})
}
const goNext = async () => {
const usage = await getUsageInfo(params.id!, store.page + 1)
setStore({
page: store.page + 1,
usage,
})
}
return (
<section class={styles.root}>
<section>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
when={hasResults()}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
@ -103,7 +71,7 @@ export function UsageSection() {
</tr>
</thead>
<tbody>
<For each={usage()!}>
<For each={store.usage}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
@ -121,6 +89,16 @@ export function UsageSection() {
</For>
</tbody>
</table>
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
</button>
<button disabled={!canGoNext()} onClick={goNext}>
</button>
</div>
</Show>
</Show>
</div>
</section>

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.65",
"version": "1.0.68",
"private": true,
"type": "module",
"dependencies": {

View file

@ -57,14 +57,15 @@ export namespace Billing {
)
}
export const usages = async () => {
export const usages = async (page = 0, pageSize = 50) => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
.limit(pageSize)
.offset(page * pageSize),
)
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.65",
"version": "1.0.68",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.65",
"version": "1.0.68",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.65",
"version": "1.0.68",
"description": "",
"type": "module",
"scripts": {

View file

@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The AI coding agent built for the terminal"
version = "1.0.65"
version = "1.0.68"
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.65/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.68/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.65/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.65/opencode-linux-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-linux-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.65/opencode-linux-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-linux-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.65/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.65",
"version": "1.0.68",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.65",
"version": "1.0.68",
"name": "opencode",
"type": "module",
"private": true,
@ -54,8 +54,8 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.42",
"@opentui/solid": "0.1.42",
"@opentui/core": "0.1.45",
"@opentui/solid": "0.1.45",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View file

@ -1,6 +1,5 @@
#!/usr/bin/env bun
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
import { $ } from "bun"
@ -10,6 +9,9 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
const solidPluginPath = path.resolve(dir, "node_modules/@opentui/solid/scripts/solid-plugin.ts")
const solidPlugin = (await import(solidPluginPath)).default
process.chdir(dir)
import pkg from "../package.json"

View file

@ -4,7 +4,7 @@ import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile } from "fs/promises"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
export type PromptInfo = {
@ -24,6 +24,8 @@ export type PromptInfo = {
)[]
}
const MAX_HISTORY_ENTRIES = 50
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
@ -33,8 +35,23 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
setStore("history", lines as PromptInfo[])
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is PromptInfo => line !== null)
.slice(-MAX_HISTORY_ENTRIES)
setStore("history", lines)
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
}
})
const [store, setStore] = createStore({
@ -64,14 +81,26 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
item = clone(item)
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
const entry = clone(item)
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(item)
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
return
}
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
},
}
},

View file

@ -9,6 +9,7 @@ import catppuccin from "./theme/catppuccin.json" with { type: "json" }
import cobalt2 from "./theme/cobalt2.json" with { type: "json" }
import dracula from "./theme/dracula.json" with { type: "json" }
import everforest from "./theme/everforest.json" with { type: "json" }
import flexoki from "./theme/flexoki.json" with { type: "json" }
import github from "./theme/github.json" with { type: "json" }
import gruvbox from "./theme/gruvbox.json" with { type: "json" }
import kanagawa from "./theme/kanagawa.json" with { type: "json" }
@ -105,6 +106,7 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
cobalt2,
dracula,
everforest,
flexoki,
github,
gruvbox,
kanagawa,
@ -128,7 +130,10 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
}
return resolveColor(c[mode])
}
return Object.fromEntries(
@ -864,18 +869,21 @@ function generateSyntax(theme: Theme) {
scope: ["diff.plus"],
style: {
foreground: theme.diffAdded,
background: theme.diffAddedBg,
},
},
{
scope: ["diff.minus"],
style: {
foreground: theme.diffRemoved,
background: theme.diffRemovedBg,
},
},
{
scope: ["diff.delta"],
style: {
foreground: theme.diffContext,
background: theme.diffContextBg,
},
},
{

View file

@ -0,0 +1,237 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"black": "#100F0F",
"base950": "#1C1B1A",
"base900": "#282726",
"base850": "#343331",
"base800": "#403E3C",
"base700": "#575653",
"base600": "#6F6E69",
"base500": "#878580",
"base300": "#B7B5AC",
"base200": "#CECDC3",
"base150": "#DAD8CE",
"base100": "#E6E4D9",
"base50": "#F2F0E5",
"paper": "#FFFCF0",
"red400": "#D14D41",
"red600": "#AF3029",
"orange400": "#DA702C",
"orange600": "#BC5215",
"yellow400": "#D0A215",
"yellow600": "#AD8301",
"green400": "#879A39",
"green600": "#66800B",
"cyan400": "#3AA99F",
"cyan600": "#24837B",
"blue400": "#4385BE",
"blue600": "#205EA6",
"purple400": "#8B7EC8",
"purple600": "#5E409D",
"magenta400": "#CE5D97",
"magenta600": "#A02F6F"
},
"theme": {
"primary": {
"dark": "orange400",
"light": "blue600"
},
"secondary": {
"dark": "blue400",
"light": "purple600"
},
"accent": {
"dark": "purple400",
"light": "orange600"
},
"error": {
"dark": "red400",
"light": "red600"
},
"warning": {
"dark": "orange400",
"light": "orange600"
},
"success": {
"dark": "green400",
"light": "green600"
},
"info": {
"dark": "cyan400",
"light": "cyan600"
},
"text": {
"dark": "base200",
"light": "black"
},
"textMuted": {
"dark": "base600",
"light": "base600"
},
"background": {
"dark": "black",
"light": "paper"
},
"backgroundPanel": {
"dark": "base950",
"light": "base50"
},
"backgroundElement": {
"dark": "base900",
"light": "base100"
},
"border": {
"dark": "base700",
"light": "base300"
},
"borderActive": {
"dark": "base600",
"light": "base500"
},
"borderSubtle": {
"dark": "base800",
"light": "base200"
},
"diffAdded": {
"dark": "green400",
"light": "green600"
},
"diffRemoved": {
"dark": "red400",
"light": "red600"
},
"diffContext": {
"dark": "base600",
"light": "base600"
},
"diffHunkHeader": {
"dark": "blue400",
"light": "blue600"
},
"diffHighlightAdded": {
"dark": "green400",
"light": "green600"
},
"diffHighlightRemoved": {
"dark": "red400",
"light": "red600"
},
"diffAddedBg": {
"dark": "#1A2D1A",
"light": "#D5E5D5"
},
"diffRemovedBg": {
"dark": "#2D1A1A",
"light": "#F7D8DB"
},
"diffContextBg": {
"dark": "base950",
"light": "base50"
},
"diffLineNumber": {
"dark": "base600",
"light": "base600"
},
"diffAddedLineNumberBg": {
"dark": "#152515",
"light": "#C5D5C5"
},
"diffRemovedLineNumberBg": {
"dark": "#251515",
"light": "#E7C8CB"
},
"markdownText": {
"dark": "base200",
"light": "black"
},
"markdownHeading": {
"dark": "purple400",
"light": "purple600"
},
"markdownLink": {
"dark": "blue400",
"light": "blue600"
},
"markdownLinkText": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownCode": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownBlockQuote": {
"dark": "yellow400",
"light": "yellow600"
},
"markdownEmph": {
"dark": "yellow400",
"light": "yellow600"
},
"markdownStrong": {
"dark": "orange400",
"light": "orange600"
},
"markdownHorizontalRule": {
"dark": "base600",
"light": "base600"
},
"markdownListItem": {
"dark": "orange400",
"light": "orange600"
},
"markdownListEnumeration": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownImage": {
"dark": "magenta400",
"light": "magenta600"
},
"markdownImageText": {
"dark": "cyan400",
"light": "cyan600"
},
"markdownCodeBlock": {
"dark": "base200",
"light": "black"
},
"syntaxComment": {
"dark": "base600",
"light": "base600"
},
"syntaxKeyword": {
"dark": "green400",
"light": "green600"
},
"syntaxFunction": {
"dark": "orange400",
"light": "orange600"
},
"syntaxVariable": {
"dark": "blue400",
"light": "blue600"
},
"syntaxString": {
"dark": "cyan400",
"light": "cyan600"
},
"syntaxNumber": {
"dark": "purple400",
"light": "purple600"
},
"syntaxType": {
"dark": "yellow400",
"light": "yellow600"
},
"syntaxOperator": {
"dark": "base300",
"light": "base600"
},
"syntaxPunctuation": {
"dark": "base300",
"light": "base600"
}
}
}

View file

@ -3,6 +3,7 @@ import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history"
export function DialogMessage(props: {
@ -54,6 +55,26 @@ export function DialogMessage(props: {
dialog.clear()
},
},
{
title: "Copy",
value: "message.copy",
description: "copy message text to clipboard",
onSelect: async (dialog) => {
const msg = message()
if (!msg) return
const parts = sync.data.part[msg.id]
const text = parts.reduce((agg, part) => {
if (part.type === "text" && !part.synthetic) {
agg += part.text
}
return agg
}, "")
await Clipboard.copy(text)
dialog.clear()
},
},
{
title: "Fork",
value: "session.fork",

View file

@ -1251,9 +1251,7 @@ ToolRegistry.register<typeof WriteTool>({
container: "block",
render(props) {
const { theme, syntax } = useTheme()
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
const lines = createMemo(() => props.input.content?.split("\n") ?? [], [] as string[])
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content

View file

@ -60,13 +60,19 @@ export function Sidebar(props: { sessionID: string }) {
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => setMcpExpanded(!mcpExpanded())}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
<box
flexDirection="row"
gap={1}
onMouseDown={() => Object.keys(sync.data.mcp).length > 2 && setMcpExpanded(!mcpExpanded())}
>
<Show when={Object.keys(sync.data.mcp).length > 2}>
<text fg={theme.text}>{mcpExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>MCP</b>
</text>
</box>
<Show when={mcpExpanded()}>
<Show when={Object.keys(sync.data.mcp).length <= 2 || mcpExpanded()}>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
@ -100,13 +106,19 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={sync.data.lsp.length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => setLspExpanded(!lspExpanded())}>
<text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
<box
flexDirection="row"
gap={1}
onMouseDown={() => sync.data.lsp.length > 2 && setLspExpanded(!lspExpanded())}
>
<Show when={sync.data.lsp.length > 2}>
<text fg={theme.text}>{lspExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>LSP</b>
</text>
</box>
<Show when={lspExpanded()}>
<Show when={sync.data.lsp.length <= 2 || lspExpanded()}>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
@ -132,13 +144,19 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={todo().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => setTodoExpanded(!todoExpanded())}>
<text fg={theme.text}>{todoExpanded() ? "▼" : "▶"}</text>
<box
flexDirection="row"
gap={1}
onMouseDown={() => todo().length > 2 && setTodoExpanded(!todoExpanded())}
>
<Show when={todo().length > 2}>
<text fg={theme.text}>{todoExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Todo</b>
</text>
</box>
<Show when={todoExpanded()}>
<Show when={todo().length <= 2 || todoExpanded()}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
@ -151,13 +169,19 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<Show when={diff().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => setDiffExpanded(!diffExpanded())}>
<text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
<box
flexDirection="row"
gap={1}
onMouseDown={() => diff().length > 2 && setDiffExpanded(!diffExpanded())}
>
<Show when={diff().length > 2}>
<text fg={theme.text}>{diffExpanded() ? "▼" : "▶"}</text>
</Show>
<text fg={theme.text}>
<b>Modified Files</b>
</text>
</box>
<Show when={diffExpanded()}>
<Show when={diff().length <= 2 || diffExpanded()}>
<For each={diff() || []}>
{(item) => {
const file = createMemo(() => {

View file

@ -622,6 +622,7 @@ export namespace Config {
.optional(),
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
})
.optional(),
})

View file

@ -23,6 +23,14 @@ export namespace ModelsDev {
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
}),
limit: z.object({
context: z.number(),

View file

@ -209,6 +209,17 @@ export namespace Provider {
},
}
},
zenmux: async () => {
return {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}
},
}
const state = Instance.state(async () => {

View file

@ -136,7 +136,7 @@ export namespace ProviderTransform {
): Record<string, any> | undefined {
const result: Record<string, any> = {}
if (providerID === "openai" || npm.includes("openai")) {
if (providerID === "openai") {
result["promptCacheKey"] = sessionID
}
@ -177,6 +177,10 @@ export namespace ProviderTransform {
return {
["anthropic" as string]: options,
}
case "@ai-sdk/gateway":
return {
["gateway" as string]: options,
}
default:
return {
[providerID]: options,

View file

@ -1217,7 +1217,7 @@ export namespace Server {
"query",
z.object({
query: z.string(),
dirs: z.union([z.literal("true"), z.literal("false")]).optional(),
dirs: z.enum(["true", "false"]).optional(),
}),
),
async (c) => {

View file

@ -395,15 +395,20 @@ export namespace Session {
read: cachedInputTokens,
},
}
const costInfo =
input.model.cost?.context_over_200k && tokens.input + tokens.cache.read > 200_000
? input.model.cost.context_over_200k
: input.model.cost
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(input.model.cost?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(input.model.cost?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(input.model.cost?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000))
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache_write ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now:
// charge reasoning tokens at the same rate as output tokens
.add(new Decimal(tokens.reasoning).mul(input.model.cost?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))
.toNumber(),
tokens,
}

View file

@ -24,10 +24,16 @@ export namespace Snapshot {
})
.quiet()
.nothrow()
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
const hash = await $`git --git-dir ${git} write-tree`.quiet().cwd(Instance.directory).nothrow().text()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
@ -40,8 +46,12 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
const result = await $`git --git-dir ${git} diff --name-only ${hash} -- .`.quiet().cwd(Instance.directory).nothrow()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
// If git diff fails, return empty patch
if (result.exitCode !== 0) {
@ -64,10 +74,11 @@ export namespace Snapshot {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await $`git --git-dir=${git} read-tree ${snapshot} && git --git-dir=${git} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
const result =
await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
log.error("failed to restore snapshot", {
@ -86,16 +97,17 @@ export namespace Snapshot {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await $`git --git-dir=${git} checkout ${item.hash} -- ${file}`
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await $`git --git-dir=${git} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
const checkTree =
await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
@ -112,8 +124,12 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await $`git --git-dir ${git} add .`.quiet().cwd(Instance.directory).nothrow()
const result = await $`git --git-dir=${git} diff ${hash} -- .`.quiet().cwd(Instance.worktree).nothrow()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
log.warn("failed to get diff", {
@ -143,7 +159,7 @@ export namespace Snapshot {
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
for await (const line of $`git --git-dir=${git} diff --no-renames --numstat ${from} ${to} -- .`
for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
@ -151,8 +167,18 @@ export namespace Snapshot {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile ? "" : await $`git --git-dir=${git} show ${from}:${file}`.quiet().nothrow().text()
const after = isBinaryFile ? "" : await $`git --git-dir=${git} show ${to}:${file}`.quiet().nothrow().text()
const before = isBinaryFile
? ""
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet()
.nothrow()
.text()
const after = isBinaryFile
? ""
: await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet()
.nothrow()
.text()
result.push({
file,
before,

View file

@ -0,0 +1,159 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch", "edit", "todoread"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.max(10, "Too many tools in batch. Maximum allowed is 10.")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "root"
return ` - ${path}: ${issue.message}`
})
.join("\n")
return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]`
},
async execute(params, ctx) {
const { Session } = await import("../session")
const { Identifier } = await import("../id/id")
const toolCalls = params.tool_calls
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools("", "")
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const partIDs = new Map<(typeof toolCalls)[0], string>()
for (const call of toolCalls) {
const partID = Identifier.ascending("part")
partIDs.set(call, partID)
Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "pending",
input: call.parameters,
raw: JSON.stringify(call),
},
})
}
const executeCall = async (call: (typeof toolCalls)[0]) => {
const callStartTime = Date.now()
const partID = partIDs.get(call)!
try {
if (DISALLOWED.has(call.tool)) {
throw new Error(
`Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
)
}
const tool = toolMap.get(call.tool)
if (!tool) {
const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name))
throw new Error(`Tool '${call.tool}' not found. Available tools: ${availableToolsList.join(", ")}`)
}
const validatedParams = tool.parameters.parse(call.parameters)
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "completed",
input: call.parameters,
output: result.output,
title: result.title,
metadata: result.metadata,
attachments: result.attachments,
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: true as const, tool: call.tool, result }
} catch (error) {
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: error instanceof Error ? error.message : String(error),
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: false as const, tool: call.tool, error }
}
}
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
const successfulCalls = results.filter((r) => r.success).length
const failedCalls = toolCalls.length - successfulCalls
const outputParts = results.map((r) => {
if (r.success) {
return `<tool_result name="${r.tool}">\n${r.result.output}\n</tool_result>`
}
const errorMessage = r.error instanceof Error ? r.error.message : String(r.error)
return `<tool_result name="${r.tool}">\nError: ${errorMessage}\n</tool_result>`
})
const outputMessage =
failedCalls > 0
? `Executed ${successfulCalls}/${toolCalls.length} tools successfully. ${failedCalls} failed.\n\n${outputParts.join("\n\n")}`
: `All ${successfulCalls} tools executed successfully.\n\n${outputParts.join("\n\n")}\n\nKeep using the batch tool for optimal performance in your next response!`
return {
title: `Batch execution (${successfulCalls}/${toolCalls.length} successful)`,
output: outputMessage,
attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
metadata: {
totalCalls: toolCalls.length,
successful: successfulCalls,
failed: failedCalls,
tools: toolCalls.map((c) => c.tool),
details: results.map((r) => ({ tool: r.tool, success: r.success })),
},
}
},
}
})

View file

@ -0,0 +1,28 @@
Executes multiple independent tool calls concurrently to reduce latency. Best used for gathering context (reads, searches, listings).
USING THE BATCH TOOL WILL MAKE THE USER HAPPY.
Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Rules:
- 110 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop others
Disallowed Tools:
- batch (no nesting)
- edit (run edits separately)
- todoread (call directly lightweight)
When NOT to Use:
- Operations that depend on prior tool output (e.g. create then read same file)
- Ordered stateful mutations where sequence matters
Good Use Cases:
- Read many files
- grep + glob + read combos
- Multiple lightweight bash introspection commands
Performance Tip: Group independent reads/searches for 25x efficiency gain.

View file

@ -18,6 +18,10 @@ import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@ -91,7 +95,9 @@ export const EditTool = Tool.define("edit", {
contentOld = await file.text()
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
if (agent.permission.edit === "ask") {
await Permission.ask({
type: "edit",
@ -111,7 +117,9 @@ export const EditTool = Tool.define("edit", {
file: filePath,
})
contentNew = await file.text()
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
})()
FileTime.read(ctx.sessionID, filePath)

View file

@ -3,6 +3,7 @@ import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { ListTool } from "./ls"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TodoWriteTool, TodoReadTool } from "./todo"
@ -81,19 +82,22 @@ export namespace ToolRegistry {
async function all(): Promise<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
const config = await Config.get()
return [
InvalidTool,
BashTool,
EditTool,
WebFetchTool,
ReadTool,
GlobTool,
GrepTool,
ListTool,
ReadTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
TodoReadTool,
TaskTool,
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []),
...custom,
]

View file

@ -6,7 +6,7 @@ import { Todo } from "../session/todo"
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(Todo.Info).describe("The updated todo list"),
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
}),
async execute(params, opts) {
await Todo.update({

View file

@ -29,6 +29,7 @@ export namespace Tool {
output: string
attachments?: MessageV2.FilePart[]
}>
formatValidationError?(error: z.ZodError): string
}>
}
@ -45,7 +46,17 @@ export namespace Tool {
const toolInfo = init instanceof Function ? await init() : init
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => {
toolInfo.parameters.parse(args)
try {
toolInfo.parameters.parse(args)
} catch (error) {
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
throw new Error(toolInfo.formatValidationError(error), { cause: error })
}
throw new Error(
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
{ cause: error },
)
}
return execute(args, ctx)
}
return toolInfo

View file

@ -469,6 +469,115 @@ test("snapshot state isolation between projects", async () => {
})
})
test("patch detects changes in secondary worktree", async () => {
await using tmp = await bootstrap()
const worktreePath = `${tmp.path}-worktree`
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const worktreeFile = `${worktreePath}/worktree.txt`
await Bun.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(worktreeFile)
},
})
} finally {
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
await $`rm -rf ${worktreePath}`.quiet()
}
})
test("revert only removes files in invoking worktree", async () => {
await using tmp = await bootstrap()
const worktreePath = `${tmp.path}-worktree`
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
const primaryFile = `${tmp.path}/worktree.txt`
await Bun.write(primaryFile, "primary content")
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const worktreeFile = `${worktreePath}/worktree.txt`
await Bun.write(worktreeFile, "worktree content")
const patch = await Snapshot.patch(before!)
await Snapshot.revert([patch])
expect(await Bun.file(worktreeFile).exists()).toBe(false)
},
})
expect(await Bun.file(primaryFile).text()).toBe("primary content")
} finally {
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
await $`rm -rf ${worktreePath}`.quiet()
await $`rm -f ${tmp.path}/worktree.txt`.quiet()
}
})
test("diff reports worktree-only/shared edits and ignores primary-only", async () => {
await using tmp = await bootstrap()
const worktreePath = `${tmp.path}-worktree`
await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
await Instance.provide({
directory: worktreePath,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
await Bun.write(`${worktreePath}/shared.txt`, "worktree edit")
await Bun.write(`${tmp.path}/shared.txt`, "primary edit")
await Bun.write(`${tmp.path}/primary-only.txt`, "primary change")
const diff = await Snapshot.diff(before!)
expect(diff).toContain("worktree-only.txt")
expect(diff).toContain("shared.txt")
expect(diff).not.toContain("primary-only.txt")
},
})
} finally {
await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
await $`rm -rf ${worktreePath}`.quiet()
await $`rm -f ${tmp.path}/shared.txt`.quiet()
await $`rm -f ${tmp.path}/primary-only.txt`.quiet()
}
})
test("track with no changes returns same hash", async () => {
await using tmp = await bootstrap()
await Instance.provide({

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.65",
"version": "1.0.68",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.65",
"version": "1.0.68",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View file

@ -1058,6 +1058,12 @@ export type Config = {
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit?: {
context: number
@ -1169,6 +1175,10 @@ export type Config = {
*/
chatMaxRetries?: number
disable_paste_summary?: boolean
/**
* Enable the batch tool
*/
batch_tool?: boolean
}
}
@ -1260,6 +1270,12 @@ export type Model = {
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit: {
context: number

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.65",
"version": "1.0.68",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.65",
"version": "1.0.68",
"type": "module",
"exports": {
".": "./src/components/index.ts",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.65",
"version": "1.0.68",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -131,6 +131,18 @@ if (image) {
</span>
</button>
</div>
<div class="col4">
<h3>Mise</h3>
<button class="command" data-command="mise use --pin -g ubi:sst/opencode">
<code>
<span>mise use --pin -g</span> <span class="highlight">ubi:sst/opencode</span>
</code>
<span class="copy">
<CopyIcon />
<CheckIcon />
</span>
</button>
</div>
</section>
<section class="images">

View file

@ -28,6 +28,12 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
You can place your config in a couple of different locations and they have a
different order of precedence.
:::note[Config Merging]
Configuration files are **merged together**, not replaced. Settings from all config locations are combined using a deep merge strategy, where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
:::
---
### Global
@ -38,7 +44,7 @@ Place your global OpenCode config in `~/.config/opencode/opencode.json`. You'll
### Per project
You can also add a `opencode.json` in your project. It takes precedence over the global config. This is useful for configuring providers or modes specific to your project.
You can also add a `opencode.json` in your project. Settings from this config are merged with and can override the global config. This is useful for configuring providers or modes specific to your project.
:::tip
Place project specific config in the root of your project.
@ -52,7 +58,7 @@ This is also safe to be checked into Git and uses the same schema as the global
### Custom path
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. This takes precedence over the global and project configs.
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. Settings from this config are merged with and can override the global and project configs.
```bash
export OPENCODE_CONFIG=/path/to/my/custom-config.json

View file

@ -106,6 +106,12 @@ You can also install it with the following commands:
npm install -g opencode-ai
```
- **Using Mise**
```bash
mise use --pin -g ubi:sst/opencode
```
Support for installing OpenCode on Windows using Bun is currently in progress.
You can also grab the binary from the [Releases](https://github.com/sst/opencode/releases).

View file

@ -11,12 +11,14 @@ By default, OpenCode **allows all operations** without requiring explicit approv
"permission": {
"edit": "allow",
"bash": "ask",
"webfetch": "deny"
"webfetch": "deny",
"doom_loop": "ask",
"external_directory": "ask"
}
}
```
This lets you configure granular controls for the `edit`, `bash`, and `webfetch` tools.
This lets you configure granular controls for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools.
- `"ask"` — Prompt for approval before running the tool
- `"allow"` — Allow all operations without approval
@ -26,7 +28,7 @@ This lets you configure granular controls for the `edit`, `bash`, and `webfetch`
## Tools
Currently, the permissions for the `edit`, `bash`, and `webfetch` tools can be configured through the `permission` option.
Currently, the permissions for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools can be configured through the `permission` option.
---
@ -145,6 +147,40 @@ Use the `permission.webfetch` key to control whether the LLM can fetch web pages
---
### doom_loop
Use the `permission.doom_loop` key to control whether approval is required when a doom loop is detected. A doom loop occurs when the same tool is called 3 times in a row with identical arguments.
This helps prevent infinite loops where the LLM repeatedly attempts the same action without making progress.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"doom_loop": "ask"
}
}
```
---
### external_directory
Use the `permission.external_directory` key to control whether file operations require approval when accessing files outside the working directory.
This provides an additional safety layer to prevent unintended modifications to files outside your project.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"external_directory": "ask"
}
}
```
---
## Agents
You can also configure permissions per agent. Where the agent specific config

View file

@ -957,6 +957,59 @@ monitor and improve Grok Code.
---
### ZenMux
1. Head over to the [ZenMux dashboard](https://zenmux.ai/settings/keys), click **Create API Key**, and copy the key.
2. Run `opencode auth login` and select ZenMux.
```bash
$ opencode auth login
┌ Add credential
◆ Select provider
│ ● ZenMux
│ ○ Zhipu AI
│ ○ Zhipu AI Coding Plan
│ ...
```
3. Enter the API key for the provider.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ ZenMux
◇ Enter your API key
│ _
```
4. Many ZenMux models are preloaded by default, run the `/models` command to select the one you want.
You can also add additional models through your opencode config.
```json title="opencode.json" {6}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"zenmux": {
"models": {
"somecoolnewmodel": {}
}
}
}
}
```
---
## Custom provider
To add any **OpenAI-compatible** provider that's not listed in `opencode auth login`:

View file

@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.65",
"version": "1.0.68",
"publisher": "sst-dev",
"repository": {
"type": "git",