Compare commits

...

56 Commits

Author SHA1 Message Date
Matt Silverlock 1e6be8428b github: support schedule events (#5810)
publish-github-action / publish (push) Waiting to run
Update Nix Hashes / update (push) Waiting to run
2025-12-21 08:46:54 -06:00
GitHub Action f2a46e5f39 ignore: update download stats 2025-12-21 2025-12-21 12:04:17 +00:00
opencode 28c29a5c5a release: v1.0.184
Update Nix Hashes / update (push) Waiting to run
2025-12-21 11:04:32 +00:00
Adam 79962945db fix(desktop): layout regression 2025-12-21 05:01:30 -06:00
Adam e448b1fe56 fix(desktop): better keybind tooltips 2025-12-21 04:56:20 -06:00
Adam 5cd8cc8b5e fix(desktop): todo tool title 2025-12-21 04:41:54 -06:00
Adam 219f98a9c0 fix(desktop): allow text selection 2025-12-21 04:39:54 -06:00
GitHub Action 519f4135fd chore: generate 2025-12-21 10:36:43 +00:00
Adam 1d40e89866 fix(desktop): incorrect state dir on macos 2025-12-21 04:36:02 -06:00
opencode 4cbb3bf569 release: v1.0.183
Update Nix Hashes / update (push) Waiting to run
2025-12-21 10:14:41 +00:00
Adam 588a21ad96 fix(desktop): better error reporting 2025-12-21 04:11:09 -06:00
Adam 09d76e37bf fix(desktop): non-latin file paths failed 2025-12-21 04:06:10 -06:00
Adam 5909c6d43d fix(desktop): file loading errors 2025-12-21 04:02:00 -06:00
Github Action 1b00c26ca3 Update Nix flake.lock and hashes 2025-12-21 06:11:08 +00:00
Christopher Tso 3bfa6c1907 Make CLI build script Windows-friendly (#5835) 2025-12-21 00:09:58 -06:00
Aiden Cline ddd095ba6a core: add verification that at least 1 primary agent is enabled, add regression tests (#5881) 2025-12-20 21:36:22 -06:00
GitHub Action d18f85b735 chore: generate 2025-12-21 03:07:26 +00:00
opencode 0a0bffe91c release: v1.0.182
Update Nix Hashes / update (push) Waiting to run
2025-12-21 03:07:26 +00:00
Aiden Cline 0fb6d0484d fix: regression where config would error despite valid agents 2025-12-20 21:04:37 -06:00
opencode 736f8ece84 release: v1.0.181
Update Nix Hashes / update (push) Waiting to run
2025-12-21 02:06:25 +00:00
Adam 4134f7d27d fix(desktop): layout 2025-12-20 20:02:40 -06:00
Adam 95aef9a7cc feat(desktop): new layout 2025-12-20 19:52:12 -06:00
YuY801103 76d22d50dd docs: add Traditional Chinese (Taiwan) README translation (#5861)
Co-authored-by: Yu <YuY801103@users.noreply.github.com>
2025-12-20 15:51:42 -06:00
Ryan Vogel 8442e0318b docs: clarify model ID format for OpenCode provider (#5854) 2025-12-20 13:51:13 -06:00
Aiden Cline b9f78f9c78 tweak: better error message if no primary agents are enabled 2025-12-20 13:47:28 -06:00
Ryan Vogel 2ca11721dc feat(docs): adding .md to docs pages shows raw markdown (#5823) 2025-12-20 12:05:06 -06:00
shamil2 f43f4b543d feat: add Catppuccin Frappé theme (#5821)
Co-authored-by: shamil2 <shamil2@users.noreply.github.com>
2025-12-20 12:04:35 -06:00
ja 4da81045e9 docs: add name property to model configuration example (#5853) 2025-12-20 11:54:49 -06:00
Shpetim 73d2bb1e04 fix: system theme flicker (#5842)
Co-authored-by: Shpetim <shpetim.alimi@ndbit.net>
2025-12-20 11:53:46 -06:00
Frank 8d27c9678e zen: sync 2025-12-20 12:52:55 -05:00
Matt Silverlock f33003881d feat: support configuring a default_agent across all API/user surfaces (#5843)
publish-github-action / publish (push) Waiting to run
Update Nix Hashes / update (push) Waiting to run
Co-authored-by: observerw <observerw@users.noreply.github.com>
2025-12-20 11:49:23 -06:00
Aiden Cline ff5065bbd1 ci: adjust review agent prompt to discourage bad diffs 2025-12-20 11:43:59 -06:00
lif 08a3d97002 fix: add transform case for gemini if mcp tool has missing array items (#5846) 2025-12-20 11:41:52 -06:00
ja 48744d79c9 fix: prioritize session list loading when resuming with -c (#5816)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-20 11:39:26 -06:00
Ryan Cassidy 4dba6a2376 feat: add cursor theme (#5850) 2025-12-20 10:56:27 -06:00
GitHub Action 4a8fd268d0 chore: generate 2025-12-20 16:00:36 +00:00
opencode 6749a9e367 release: v1.0.180
Update Nix Hashes / update (push) Waiting to run
2025-12-20 16:00:36 +00:00
Dax Raad 1b680268ac ci 2025-12-20 10:54:02 -05:00
opencode 1d5c01d4d6 release: v1.0.179
Update Nix Hashes / update (push) Waiting to run
2025-12-20 15:38:38 +00:00
Dax Raad 165c8aba78 ci 2025-12-20 10:35:01 -05:00
GitHub Action 0b4d55a3f0 chore: generate 2025-12-20 15:29:23 +00:00
Dax Raad 5e990c8dee ci 2025-12-20 10:28:46 -05:00
opencode 41d5925f1a release: v1.0.178
Update Nix Hashes / update (push) Waiting to run
2025-12-20 15:08:27 +00:00
Dax Raad b2642f93f3 ci 2025-12-20 10:05:03 -05:00
Tommy D. Rossi 0d0b615d1a feat: add endpoints to delete and update message parts (#5433) 2025-12-20 15:00:41 +00:00
GitHub Action 4ac0b7ce05 chore: generate 2025-12-20 15:00:41 +00:00
opencode 69f18f25a6 release: v1.0.177
Update Nix Hashes / update (push) Waiting to run
2025-12-20 15:00:40 +00:00
Dax Raad a94fdc46ff ci 2025-12-20 09:57:19 -05:00
opencode 4e69fd7873 release: v1.0.176
Update Nix Hashes / update (push) Waiting to run
2025-12-20 12:28:27 +00:00
Adam fe5b059bd4 fix(desktop): sidebar UX issues 2025-12-20 06:25:39 -06:00
Adam f7a8f34089 fix(desktop): task rendering perf 2025-12-20 06:15:59 -06:00
GitHub Action 013e9825da chore: generate 2025-12-20 12:13:52 +00:00
Adam 3ff967e72a fix(desktop): show last text part when summarized 2025-12-20 06:12:58 -06:00
Adam 484ab0b32b fix(desktop): performance with lots of session changes 2025-12-20 06:12:58 -06:00
Adam 9143e9ef2f fix(desktop): event reconnect gaps 2025-12-20 06:12:58 -06:00
GitHub Action d24f3709bd ignore: update download stats 2025-12-20 2025-12-20 12:04:14 +00:00
74 changed files with 2066 additions and 501 deletions
+11 -14
View File
@@ -80,8 +80,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: false
outputs:
releaseId: ${{ steps.publish.outputs.releaseId }}
tagName: ${{ steps.publish.outputs.tagName }}
release: ${{ steps.publish.outputs.release }}
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
publish-tauri:
needs: publish
@@ -103,7 +104,7 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tagName }}
ref: ${{ needs.publish.outputs.tag }}
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
@@ -146,20 +147,18 @@ jobs:
shared-key: ${{ matrix.settings.target }}
- name: Prepare
if: inputs.bump || inputs.version
run: |
cd packages/tauri
bun ./scripts/prepare.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_CHANNEL: latest
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
OPENCODE_RELEASE_TAG: ${{ needs.publish.outputs.tagName }}
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
@@ -189,8 +188,8 @@ jobs:
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.releaseId }}
tagName: ${{ needs.publish.outputs.tagName }}
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
@@ -198,13 +197,13 @@ jobs:
needs:
- publish
- publish-tauri
if: needs.publish.outputs.tagName
if: needs.publish.outputs.tag
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tagName }}
ref: ${{ needs.publish.outputs.tag }}
- uses: ./.github/actions/setup-bun
@@ -221,8 +220,6 @@ jobs:
- run: ./script/publish-complete.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
OPENCODE_RELEASE_TAG: ${{ needs.publish.outputs.tagName }}
+2
View File
@@ -67,6 +67,8 @@ jobs:
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
Generally, write a comment instead of writing suggested change if you can help it.
Command MUST be like this.
\`\`\`
+115
View File
@@ -0,0 +1,115 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">開源的 AI Coding Agent。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### 安裝
```bash
# 直接安裝 (YOLO)
curl -fsSL https://opencode.ai/install | bash
# 套件管理員
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:sst/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
```
> [!TIP]
> 安裝前請先移除 0.1.x 以前的舊版本。
### 桌面應用程式 (BETA)
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
| 平台 | 下載連結 |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, 或 AppImage |
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
```
#### 安裝目錄
安裝腳本會依據以下優先順序決定安裝路徑:
1. `$OPENCODE_INSTALL_DIR` - 自定義安裝目錄
2. `$XDG_BIN_DIR` - 符合 XDG 基礎目錄規範的路徑
3. `$HOME/bin` - 標準使用者執行檔目錄 (若存在或可建立)
4. `$HOME/.opencode/bin` - 預設備用路徑
```bash
# 範例
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents
OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
- **build** - 預設模式,具備完整權限的 Agent,適用於開發工作。
- **plan** - 唯讀模式,適用於程式碼分析與探索。
- 預設禁止修改檔案。
- 執行 bash 指令前會詢問權限。
- 非常適合用來探索陌生的程式碼庫或規劃變更。
此外,OpenCode 還包含一個 **general** 子 Agent,用於處理複雜搜尋與多步驟任務。此 Agent 供系統內部使用,亦可透過在訊息中輸入 `@general` 來呼叫。
了解更多關於 [Agents](https://opencode.ai/docs/agents) 的資訊。
### 線上文件
關於如何設定 OpenCode 的詳細資訊,請參閱我們的 [**官方文件**](https://opencode.ai/docs)。
### 參與貢獻
如果您有興趣參與 OpenCode 的開發,請在提交 Pull Request 前先閱讀我們的 [貢獻指南 (Contributing Docs)](./CONTRIBUTING.md)。
### 基於 OpenCode 進行開發
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
### 常見問題 (FAQ)
#### 這跟 Claude Code 有什麼不同?
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
#### 另一個同名的 Repo 是什麼?
另一個名稱相近的儲存庫與本專案無關。您可以點此[閱讀背後的故事](https://x.com/thdxr/status/1933561254481666466)。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+2
View File
@@ -175,3 +175,5 @@
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
+15 -15
View File
@@ -20,7 +20,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -48,7 +48,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -75,7 +75,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -99,7 +99,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -123,7 +123,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -171,7 +171,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -200,7 +200,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.175",
"version": "1.0.184",
"bin": {
"opencode": "./bin/opencode",
},
@@ -308,7 +308,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -328,7 +328,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.175",
"version": "1.0.184",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -339,7 +339,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -352,7 +352,7 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@opencode-ai/desktop": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -379,7 +379,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -414,7 +414,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"zod": "catalog:",
},
@@ -425,7 +425,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766025857,
"narHash": "sha256-Lav5jJazCW4mdg1iHcROpuXqmM94BWJvabLFWaJVJp0=",
"lastModified": 1766125104,
"narHash": "sha256-l/YGrEpLromL4viUo5GmFH3K5M1j0Mb9O+LiaeCPWEM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "def3da69945bbe338c373fddad5a1bb49cf199ce",
"rev": "7d853e518814cca2a657b72eeba67ae20ebf7059",
"type": "github"
},
"original": {
+5
View File
@@ -9,6 +9,10 @@ inputs:
description: "Model to use"
required: true
agent:
description: "Agent to use. Must be a primary agent. Falls back to default_agent from config or 'build' if not found."
required: false
share:
description: "Share the opencode session (defaults to true for public repos)"
required: false
@@ -62,6 +66,7 @@ runs:
run: opencode github run
env:
MODEL: ${{ inputs.model }}
AGENT: ${{ inputs.agent }}
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
+31 -2
View File
@@ -318,6 +318,10 @@ function useEnvRunUrl() {
return `/${repo.owner}/${repo.repo}/actions/runs/${runId}`
}
function useEnvAgent() {
return process.env["AGENT"] || undefined
}
function useEnvShare() {
const value = process.env["SHARE"]
if (!value) return undefined
@@ -570,24 +574,49 @@ async function subscribeSessionEvents() {
}
async function summarize(response: string) {
const payload = useContext().payload as IssueCommentEvent
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
if (isScheduleEvent()) {
return "Scheduled task changes"
}
const payload = useContext().payload as IssueCommentEvent
return `Fix issue: ${payload.issue.title}`
}
}
async function resolveAgent(): Promise<string | undefined> {
const envAgent = useEnvAgent()
if (!envAgent) return undefined
// Validate the agent exists and is a primary agent
const agents = await client.agent.list<true>()
const agent = agents.data?.find((a) => a.name === envAgent)
if (!agent) {
console.warn(`agent "${envAgent}" not found. Falling back to default agent`)
return undefined
}
if (agent.mode === "subagent") {
console.warn(`agent "${envAgent}" is a subagent, not a primary agent. Falling back to default agent`)
return undefined
}
return envAgent
}
async function chat(text: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
const { providerID, modelID } = useEnvModel()
const agent = await resolveAgent()
const chat = await client.session.chat<true>({
path: session,
body: {
providerID,
modelID,
agent: "build",
agent,
parts: [
{
type: "text",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.175",
"version": "1.0.184",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.175",
"version": "1.0.184",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.175",
"version": "1.0.184",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.175",
"version": "1.0.184",
"description": "",
"type": "module",
"exports": {
+15 -4
View File
@@ -10,6 +10,7 @@ import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
@@ -24,6 +25,7 @@ export function Header(props: {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
@@ -80,9 +82,18 @@ export function Header(props: {
/>
</div>
<Show when={currentSession()}>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
<Tooltip
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
@@ -91,7 +102,7 @@ export function Header(props: {
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">Ctrl `</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
@@ -19,7 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand, formatKeybind } from "@/context/command"
import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
@@ -889,8 +889,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
custom
</span>
</Show>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
<Show when={command.keybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
</Show>
</div>
</button>
@@ -990,26 +990,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Match>
<Match when={store.mode === "normal"}>
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() =>
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
)
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Cycle agent</span>
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
</div>
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
</Tooltip>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Choose model</span>
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</div>
}
>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() =>
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
)
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
</Match>
</Switch>
</div>
+5
View File
@@ -226,6 +226,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
}
},
keybind(id: string) {
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
if (!option?.keybind) return ""
return formatKeybind(option.keybind)
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
+1 -2
View File
@@ -8,8 +8,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
init: (props: { url: string }) => {
const eventSdk = createOpencodeClient({
baseUrl: props.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
throwOnError: true,
// signal: AbortSignal.timeout(1000 * 60 * 10),
})
const emitter = createGlobalEmitter<{
[key: string]: Event
+10 -17
View File
@@ -46,8 +46,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: false,
height: 280,
},
review: {
state: "pane" as "pane" | "tab",
session: {
width: 600,
},
sessionTabs: {} as Record<string, SessionTabs>,
}),
@@ -156,13 +156,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "height", height)
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
setStore("review", "state", "pane")
},
tab() {
setStore("review", "state", "tab")
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
if (!store.session) {
setStore("session", { width })
} else {
setStore("session", "width", width)
}
},
},
tabs(sessionKey: string) {
@@ -186,14 +187,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
async open(tab: string) {
if (tab === "chat") {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: undefined })
} else {
setStore("sessionTabs", sessionKey, "active", undefined)
}
return
}
const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (!current.all.includes(tab)) {
+2 -2
View File
@@ -360,7 +360,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const init = async (path: string) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
if (store.node[relativePath].loaded) return
if (store.node[relativePath]?.loaded) return
return load(relativePath)
}
@@ -380,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath].loaded) return
if (store.node[relativePath]?.loaded) return
return load(relativePath)
}
+25 -5
View File
@@ -62,12 +62,32 @@ function formatInitError(error: InitError): string {
}
}
function formatError(error: unknown): string {
function formatErrorChain(error: unknown, depth = 0): string {
if (!error) return "Unknown error"
if (isInitError(error)) return formatInitError(error)
if (error instanceof Error) return `${error.name}: ${error.message}\n\n${error.stack}`
if (typeof error === "string") return error
return JSON.stringify(error, null, 2)
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
if (isInitError(error)) {
return indent + formatInitError(error)
}
if (error instanceof Error) {
const parts = [indent + `${error.name}: ${error.message}`]
if (error.stack) {
parts.push(error.stack)
}
if (error.cause) {
parts.push(formatErrorChain(error.cause, depth + 1))
}
return parts.join("\n\n")
}
if (typeof error === "string") return indent + error
return indent + JSON.stringify(error, null, 2)
}
function formatError(error: unknown): string {
return formatErrorChain(error, 0)
}
interface ErrorPageProps {
+33 -17
View File
@@ -1,15 +1,4 @@
import {
createEffect,
createMemo,
createSignal,
For,
Match,
onMount,
ParentProps,
Show,
Switch,
type JSX,
} from "solid-js"
import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
@@ -542,13 +531,21 @@ export default function Layout(props: ParentProps) {
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
const [expanded, setExpanded] = createSignal(true)
const handleOpenChange = (open: boolean) => {
if (open) layout.projects.expand(props.project.worktree)
else layout.projects.collapse(props.project.worktree)
}
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
<Match when={layout.sidebar.opened()}>
<Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
<Collapsible
variant="ghost"
open={props.project.expanded}
class="gap-2 shrink-0"
onOpenChange={handleOpenChange}
>
<Button
as={"div"}
variant="ghost"
@@ -559,7 +556,7 @@ export default function Layout(props: ParentProps) {
project={props.project}
class="group-hover/session:hidden"
expandable
notify={!expanded()}
notify={!props.project.expanded}
/>
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</Collapsible.Trigger>
@@ -677,7 +674,17 @@ export default function Layout(props: ParentProps) {
/>
</Show>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Tooltip
class="shrink-0"
placement="right"
value={
<div class="flex items-center gap-2">
<span>Toggle sidebar</span>
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
inactive={layout.sidebar.opened()}
>
<Button
variant="ghost"
size="large"
@@ -765,7 +772,16 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Tooltip
placement="right"
value={
<div class="flex items-center gap-2">
<span>Open project</span>
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
</div>
}
inactive={layout.sidebar.opened()}
>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
+218 -277
View File
@@ -22,7 +22,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -50,7 +49,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
@@ -118,27 +117,8 @@ export default function Page() {
setActiveMessage(msgs[targetIndex])
}
const last = createMemo(
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
const t = last().tokens
return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
})
const context = createMemo(() => {
const total = tokens()
const limit = model()?.limit.context
if (!total || !limit) return 0
return Math.round((total / limit) * 100)
})
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
@@ -551,273 +531,226 @@ export default function Page() {
)
}
const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
<div class="min-h-0 grow w-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<div class="min-h-0 grow w-full flex">
{/* Session pane - always visible */}
<div
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<ProgressCircle percentage={context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.Trigger>
<Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
</Tooltip>
}
>
<div class="flex items-center gap-3">
<Show when={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={tabs().all() ?? []}>
<For each={tabs().all() ?? []}>
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<Tooltip value="Open file" class="flex items-center">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile />)}
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={!showTabs()}
/>
<Show when={activeMessage()}>
<SessionTurn
sessionID={params.id!}
messageID={activeMessage()!.id}
stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(!showTabs()
? "max-w-200 mx-auto px-6"
: visibleUserMessages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
/>
</Tooltip>
</Show>
</div>
</Tabs.List>
</div>
<Tabs.Content
value="chat"
class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden contain-strict"
>
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<div
classList={{
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
"max-w-146 mx-auto": !wide(),
}}
>
<Switch>
<Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<Show when={activeMessage()}>
<SessionTurn
sessionID={params.id!}
messageID={activeMessage()!.id}
stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(wide()
? "max-w-200 mx-auto px-6"
: visibleUserMessages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
)}
</Show>
</div>
</Match>
</Switch>
</div>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div
classList={{
"w-full px-6": true,
"max-w-200": !showTabs(),
}}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
<Show when={showTabs()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
</Show>
</div>
{/* Tabs pane - visible when there are diffs or file tabs */}
<Show when={showTabs()}>
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={diffs().length}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div class="w-full max-w-200 px-6">
<PromptInput
ref={(el) => {
inputRef = el
</Tabs.Trigger>
</Show>
<SortableProvider ids={tabs().all() ?? []}>
<For each={tabs().all() ?? []}>
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>Open file</span>
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
</div>
}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile />)}
/>
</Tooltip>
</div>
</Tabs.List>
</div>
<Show when={diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
split
/>
</div>
</div>
</div>
<Show when={layout.review.state() === "pane" && diffs().length}>
<div
classList={{
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base contain-strict": true,
}}
>
<SessionReview
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
tabs().setActive("review")
}}
/>
</Tooltip>
}
/>
</div>
</Show>
</div>
</Tabs.Content>
<Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
<div
classList={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
split
/>
</div>
</Tabs.Content>
</Show>
<For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Dynamic
component={codeComponent}
file={{
name: f().path,
contents: f().content?.content ?? "",
cacheKey: checksum(f().content?.content ?? ""),
}}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={tabs().active()}>
<div class="absolute inset-x-0 px-6 max-w-200 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</Show>
<For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Dynamic
component={codeComponent}
file={{
name: f().path,
contents: f().content?.content ?? "",
cacheKey: checksum(f().content?.content ?? ""),
}}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</Show>
</div>
@@ -849,7 +782,15 @@ export default function Page() {
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<Tooltip value="New Terminal" class="flex items-center">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>New terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
</div>
}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</Tooltip>
</div>
+1
View File
@@ -10,5 +10,6 @@ export default defineConfig({
},
build: {
target: "esnext",
sourcemap: true,
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.175",
"version": "1.0.184",
"private": true,
"type": "module",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.175"
version = "1.0.184"
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.175/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.184/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.175/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.184/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.175/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.184/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.175/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.184/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.175/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.184/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.175",
"version": "1.0.184",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2 -2
View File
@@ -1,13 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.175",
"version": "1.0.184",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test",
"build": "./script/build.ts",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
+14 -1
View File
@@ -16,6 +16,7 @@ import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const allTargets: {
@@ -78,7 +79,19 @@ const allTargets: {
]
const targets = singleFlag
? allTargets.filter((item) => item.os === process.platform && item.arch === process.arch)
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) {
return false
}
// When building for the current platform, prefer a single native binary by default.
// Baseline binaries require additional Bun artifacts and can be flaky to download.
if (item.avx2 === false) {
return baselineFlag
}
return true
})
: allTargets
await $`rm -rf dist`
+4 -2
View File
@@ -22,6 +22,7 @@ import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig, ACPSessionState } from "./types"
import { Provider } from "../provider/provider"
import { Agent as AgentModule } from "../agent/agent"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
@@ -705,7 +706,8 @@ export namespace ACP {
description: agent.description,
}))
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
const defaultAgentName = await AgentModule.defaultAgent()
const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id
const mcpServers: Record<string, Config.Mcp> = {}
for (const server of params.mcpServers) {
@@ -807,7 +809,7 @@ export namespace ACP {
if (!current) {
this.sessionManager.setModel(session.id, model)
}
const agent = session.modeId ?? "build"
const agent = session.modeId ?? (await AgentModule.defaultAgent())
const parts: Array<
{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
+31
View File
@@ -5,6 +5,9 @@ import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { mergeDeep } from "remeda"
import { Log } from "../util/log"
const log = Log.create({ service: "agent" })
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -20,6 +23,7 @@ export namespace Agent {
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
default: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
@@ -245,6 +249,27 @@ export namespace Agent {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
}
// Mark the default agent
const defaultName = cfg.default_agent ?? "build"
const defaultCandidate = result[defaultName]
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
defaultCandidate.default = true
} else {
// Fall back to "build" if configured default is invalid
if (result["build"]) {
result["build"].default = true
}
}
const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
if (!hasPrimaryAgents) {
throw new Config.InvalidError({
path: "config",
message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
})
}
return result
})
@@ -256,6 +281,12 @@ export namespace Agent {
return state().then((x) => Object.values(x))
}
export async function defaultAgent(): Promise<string> {
const agents = await state()
const defaultCandidate = Object.values(agents).find((a) => a.default)
return defaultCandidate?.name ?? "build"
}
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
const cfg = await Config.get()
const defaultModel = input.model ?? (await Provider.defaultModel())
+98 -41
View File
@@ -127,6 +127,7 @@ type IssueQueryResponse = {
const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule"] as const
// Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git
@@ -387,22 +388,27 @@ export const GithubRunCommand = cmd({
const isMock = args.token || args.event
const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
if (!SUPPORTED_EVENTS.includes(context.eventName as (typeof SUPPORTED_EVENTS)[number])) {
core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1)
}
const isScheduleEvent = context.eventName === "schedule"
const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
const actor = context.actor
// For schedule events, payload has no issue/comment data
const payload = isScheduleEvent
? undefined
: (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
const actor = isScheduleEvent ? undefined : context.actor
const issueId =
context.eventName === "pull_request_review_comment"
const issueId = isScheduleEvent
? undefined
: context.eventName === "pull_request_review_comment"
? (payload as PullRequestReviewCommentEvent).pull_request.number
: (payload as IssueCommentEvent).issue.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
@@ -416,9 +422,13 @@ export const GithubRunCommand = cmd({
let shareId: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = payload.comment.id
const triggerCommentId = payload?.comment.id
const useGithubToken = normalizeUseGithubToken()
const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
const commentType = isScheduleEvent
? undefined
: context.eventName === "pull_request_review_comment"
? "pr_review"
: "issue"
try {
if (useGithubToken) {
@@ -442,9 +452,11 @@ export const GithubRunCommand = cmd({
if (!useGithubToken) {
await configureGit(appToken)
}
await assertPermissions()
await addReaction(commentType)
// Skip permission check for schedule events (no actor to check)
if (!isScheduleEvent) {
await assertPermissions()
await addReaction(commentType!)
}
// Setup opencode session
const repoData = await fetchRepo()
@@ -458,11 +470,31 @@ export const GithubRunCommand = cmd({
})()
console.log("opencode session", session.id)
// Handle 3 cases
// 1. Issue
// 2. Local PR
// 3. Fork PR
if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
// Handle 4 cases
// 1. Schedule (no issue/PR context)
// 2. Issue
// 3. Local PR
// 4. Fork PR
if (isScheduleEvent) {
// Schedule event - no issue/PR context, output goes to logs
const branch = await checkoutNewBranch("schedule")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges, true)
const pr = await createPR(
repoData.data.default_branch,
branch,
summary,
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
} else {
console.log("Response:", response)
}
} else if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
const prData = await fetchPR()
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -477,7 +509,7 @@ export const GithubRunCommand = cmd({
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction(commentType)
await removeReaction(commentType!)
}
// Fork PR
else {
@@ -492,12 +524,12 @@ export const GithubRunCommand = cmd({
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction(commentType)
await removeReaction(commentType!)
}
}
// Issue
else {
const branch = await checkoutNewBranch()
const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
@@ -505,7 +537,7 @@ export const GithubRunCommand = cmd({
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges)
await pushToNewBranch(summary, branch, uncommittedChanges, false)
const pr = await createPR(
repoData.data.default_branch,
branch,
@@ -513,10 +545,10 @@ export const GithubRunCommand = cmd({
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
await createComment(`Created PR #${pr}${footer({ image: true })}`)
await removeReaction(commentType)
await removeReaction(commentType!)
} else {
await createComment(`${response}${footer({ image: true })}`)
await removeReaction(commentType)
await removeReaction(commentType!)
}
}
} catch (e: any) {
@@ -528,8 +560,10 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
await createComment(`${msg}${footer()}`)
await removeReaction(commentType)
if (!isScheduleEvent) {
await createComment(`${msg}${footer()}`)
await removeReaction(commentType!)
}
core.setFailed(msg)
// Also output the clean error message for the action to capture
//core.setOutput("prepare_error", e.message);
@@ -605,6 +639,14 @@ export const GithubRunCommand = cmd({
async function getUserPrompt() {
const customPrompt = process.env["PROMPT"]
// For schedule events, PROMPT is required since there's no comment to extract from
if (isScheduleEvent) {
if (!customPrompt) {
throw new Error("PROMPT input is required for scheduled events")
}
return { userPrompt: customPrompt, promptFiles: [] }
}
if (customPrompt) {
return { userPrompt: customPrompt, promptFiles: [] }
}
@@ -615,7 +657,7 @@ export const GithubRunCommand = cmd({
.map((m) => m.trim().toLowerCase())
.filter(Boolean)
let prompt = (() => {
const body = payload.comment.body.trim()
const body = payload!.comment.body.trim()
const bodyLower = body.toLowerCase()
if (mentions.some((m) => bodyLower === m)) {
if (reviewContext) {
@@ -762,7 +804,7 @@ export const GithubRunCommand = cmd({
providerID,
modelID,
},
agent: "build",
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: Identifier.ascending("part"),
@@ -865,9 +907,9 @@ export const GithubRunCommand = cmd({
await $`git config --local ${config} "${gitConfig}"`
}
async function checkoutNewBranch() {
async function checkoutNewBranch(type: "issue" | "schedule") {
console.log("Checking out new branch...")
const branch = generateBranchName("issue")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
return branch
}
@@ -894,23 +936,32 @@ export const GithubRunCommand = cmd({
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
}
function generateBranchName(type: "issue" | "pr") {
function generateBranchName(type: "issue" | "pr" | "schedule") {
const timestamp = new Date()
.toISOString()
.replace(/[:-]/g, "")
.replace(/\.\d{3}Z/, "")
.split("T")
.join("")
if (type === "schedule") {
const hex = crypto.randomUUID().slice(0, 6)
return `opencode/scheduled-${hex}-${timestamp}`
}
return `opencode/${type}${issueId}-${timestamp}`
}
async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
} else {
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
}
await $`git push -u origin ${branch}`
}
@@ -958,6 +1009,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
async function assertPermissions() {
// Only called for non-schedule events, so actor is defined
console.log(`Asserting permissions for user ${actor}...`)
let permission
@@ -965,7 +1017,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const response = await octoRest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: actor,
username: actor!,
})
permission = response.data.permission
@@ -979,30 +1031,32 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
}
async function addReaction(commentType: "issue" | "pr_review") {
// Only called for non-schedule events, so triggerCommentId is defined
console.log("Adding reaction...")
if (commentType === "pr_review") {
return await octoRest.rest.reactions.createForPullRequestReviewComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
content: AGENT_REACTION,
})
}
return await octoRest.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
content: AGENT_REACTION,
})
}
async function removeReaction(commentType: "issue" | "pr_review") {
// Only called for non-schedule events, so triggerCommentId is defined
console.log("Removing reaction...")
if (commentType === "pr_review") {
const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
content: AGENT_REACTION,
})
@@ -1012,7 +1066,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await octoRest.rest.reactions.deleteForPullRequestComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
reaction_id: eyesReaction.id,
})
return
@@ -1021,7 +1075,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const reactions = await octoRest.rest.reactions.listForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
content: AGENT_REACTION,
})
@@ -1031,17 +1085,18 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await octoRest.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
comment_id: triggerCommentId!,
reaction_id: eyesReaction.id,
})
}
async function createComment(body: string) {
// Only called for non-schedule events, so issueId is defined
console.log("Creating comment...")
return await octoRest.rest.issues.createComment({
owner,
repo,
issue_number: issueId,
issue_number: issueId!,
body,
})
}
@@ -1119,10 +1174,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
}
function buildPromptDataForIssue(issue: GitHubIssue) {
// Only called for non-schedule events, so payload is defined
const comments = (issue.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== payload.comment.id
return id !== payload!.comment.id
})
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
@@ -1246,10 +1302,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
}
function buildPromptDataForPR(pr: GitHubPullRequest) {
// Only called for non-schedule events, so payload is defined
const comments = (pr.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== payload.comment.id
return id !== payload!.comment.id
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
+26 -2
View File
@@ -10,6 +10,7 @@ import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
@@ -223,10 +224,33 @@ export const RunCommand = cmd({
}
})()
// Validate agent if specified
const resolvedAgent = await (async () => {
if (!args.agent) return undefined
const agent = await Agent.get(args.agent)
if (!agent) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" not found. Falling back to default agent`,
)
return undefined
}
if (agent.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
)
return undefined
}
return args.agent
})()
if (args.command) {
await sdk.session.command({
sessionID,
agent: args.agent || "build",
agent: resolvedAgent,
model: args.model,
command: args.command,
arguments: message,
@@ -235,7 +259,7 @@ export const RunCommand = cmd({
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent: args.agent || "build",
agent: resolvedAgent,
model: modelParam,
parts: [...fileParts, { type: "text", text: message }],
})
+2 -1
View File
@@ -229,7 +229,8 @@ function App() {
let continued = false
createEffect(() => {
if (continued || sync.status !== "complete" || !args.continue) return
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
if (continued || sync.status === "loading" || !args.continue) return
const match = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
@@ -56,7 +56,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents()[0].name,
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
@@ -22,6 +22,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -254,10 +255,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const exit = useExit()
const args = useArgs()
async function bootstrap() {
// blocking
await Promise.all([
const sessionListPromise = sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
)
// blocking - include session.list when continuing a session
const blockingRequests: Promise<unknown>[] = [
sdk.client.config.providers({}, { throwOnError: true }).then((x) => {
batch(() => {
setStore("provider", x.data!.providers)
@@ -271,17 +280,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", x.data!)),
])
...(args.continue ? [sessionListPromise] : []),
]
await Promise.all(blockingRequests)
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
sdk.client.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
),
...(args.continue ? [] : [sessionListPromise]),
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
@@ -6,8 +6,10 @@ import { createSimpleContext } from "./helper"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
import catppuccinFrappe from "./theme/catppuccin-frappe.json" with { type: "json" }
import catppuccinMacchiato from "./theme/catppuccin-macchiato.json" with { type: "json" }
import cobalt2 from "./theme/cobalt2.json" with { type: "json" }
import cursor from "./theme/cursor.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" }
@@ -136,8 +138,10 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
aura,
ayu,
catppuccin,
["catppuccin-frappe"]: catppuccinFrappe,
["catppuccin-macchiato"]: catppuccinMacchiato,
cobalt2,
cursor,
dracula,
everforest,
flexoki,
@@ -279,14 +283,23 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
ready: false,
})
createEffect(async () => {
const custom = await getCustomThemes()
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
draft.ready = true
}),
)
createEffect(() => {
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom)
}),
)
})
.catch(() => {
setStore("active", "opencode")
})
.finally(() => {
if (store.active !== "system") {
setStore("ready", true)
}
})
})
const renderer = useRenderer()
@@ -295,8 +308,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
size: 16,
})
.then((colors) => {
if (!colors.palette[0]) return
setStore("themes", "system", generateSystem(colors, store.mode))
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
if (store.active === "system") {
draft.ready = true
}
}),
)
})
const values = createMemo(() => {
@@ -0,0 +1,233 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"frappeRosewater": "#f2d5cf",
"frappeFlamingo": "#eebebe",
"frappePink": "#f4b8e4",
"frappeMauve": "#ca9ee6",
"frappeRed": "#e78284",
"frappeMaroon": "#ea999c",
"frappePeach": "#ef9f76",
"frappeYellow": "#e5c890",
"frappeGreen": "#a6d189",
"frappeTeal": "#81c8be",
"frappeSky": "#99d1db",
"frappeSapphire": "#85c1dc",
"frappeBlue": "#8da4e2",
"frappeLavender": "#babbf1",
"frappeText": "#c6d0f5",
"frappeSubtext1": "#b5bfe2",
"frappeSubtext0": "#a5adce",
"frappeOverlay2": "#949cb8",
"frappeOverlay1": "#838ba7",
"frappeOverlay0": "#737994",
"frappeSurface2": "#626880",
"frappeSurface1": "#51576d",
"frappeSurface0": "#414559",
"frappeBase": "#303446",
"frappeMantle": "#292c3c",
"frappeCrust": "#232634"
},
"theme": {
"primary": {
"dark": "frappeBlue",
"light": "frappeBlue"
},
"secondary": {
"dark": "frappeMauve",
"light": "frappeMauve"
},
"accent": {
"dark": "frappePink",
"light": "frappePink"
},
"error": {
"dark": "frappeRed",
"light": "frappeRed"
},
"warning": {
"dark": "frappeYellow",
"light": "frappeYellow"
},
"success": {
"dark": "frappeGreen",
"light": "frappeGreen"
},
"info": {
"dark": "frappeTeal",
"light": "frappeTeal"
},
"text": {
"dark": "frappeText",
"light": "frappeText"
},
"textMuted": {
"dark": "frappeSubtext1",
"light": "frappeSubtext1"
},
"background": {
"dark": "frappeBase",
"light": "frappeBase"
},
"backgroundPanel": {
"dark": "frappeMantle",
"light": "frappeMantle"
},
"backgroundElement": {
"dark": "frappeCrust",
"light": "frappeCrust"
},
"border": {
"dark": "frappeSurface0",
"light": "frappeSurface0"
},
"borderActive": {
"dark": "frappeSurface1",
"light": "frappeSurface1"
},
"borderSubtle": {
"dark": "frappeSurface2",
"light": "frappeSurface2"
},
"diffAdded": {
"dark": "frappeGreen",
"light": "frappeGreen"
},
"diffRemoved": {
"dark": "frappeRed",
"light": "frappeRed"
},
"diffContext": {
"dark": "frappeOverlay2",
"light": "frappeOverlay2"
},
"diffHunkHeader": {
"dark": "frappePeach",
"light": "frappePeach"
},
"diffHighlightAdded": {
"dark": "frappeGreen",
"light": "frappeGreen"
},
"diffHighlightRemoved": {
"dark": "frappeRed",
"light": "frappeRed"
},
"diffAddedBg": {
"dark": "#29342b",
"light": "#29342b"
},
"diffRemovedBg": {
"dark": "#3a2a31",
"light": "#3a2a31"
},
"diffContextBg": {
"dark": "frappeMantle",
"light": "frappeMantle"
},
"diffLineNumber": {
"dark": "frappeSurface1",
"light": "frappeSurface1"
},
"diffAddedLineNumberBg": {
"dark": "#223025",
"light": "#223025"
},
"diffRemovedLineNumberBg": {
"dark": "#2f242b",
"light": "#2f242b"
},
"markdownText": {
"dark": "frappeText",
"light": "frappeText"
},
"markdownHeading": {
"dark": "frappeMauve",
"light": "frappeMauve"
},
"markdownLink": {
"dark": "frappeBlue",
"light": "frappeBlue"
},
"markdownLinkText": {
"dark": "frappeSky",
"light": "frappeSky"
},
"markdownCode": {
"dark": "frappeGreen",
"light": "frappeGreen"
},
"markdownBlockQuote": {
"dark": "frappeYellow",
"light": "frappeYellow"
},
"markdownEmph": {
"dark": "frappeYellow",
"light": "frappeYellow"
},
"markdownStrong": {
"dark": "frappePeach",
"light": "frappePeach"
},
"markdownHorizontalRule": {
"dark": "frappeSubtext0",
"light": "frappeSubtext0"
},
"markdownListItem": {
"dark": "frappeBlue",
"light": "frappeBlue"
},
"markdownListEnumeration": {
"dark": "frappeSky",
"light": "frappeSky"
},
"markdownImage": {
"dark": "frappeBlue",
"light": "frappeBlue"
},
"markdownImageText": {
"dark": "frappeSky",
"light": "frappeSky"
},
"markdownCodeBlock": {
"dark": "frappeText",
"light": "frappeText"
},
"syntaxComment": {
"dark": "frappeOverlay2",
"light": "frappeOverlay2"
},
"syntaxKeyword": {
"dark": "frappeMauve",
"light": "frappeMauve"
},
"syntaxFunction": {
"dark": "frappeBlue",
"light": "frappeBlue"
},
"syntaxVariable": {
"dark": "frappeRed",
"light": "frappeRed"
},
"syntaxString": {
"dark": "frappeGreen",
"light": "frappeGreen"
},
"syntaxNumber": {
"dark": "frappePeach",
"light": "frappePeach"
},
"syntaxType": {
"dark": "frappeYellow",
"light": "frappeYellow"
},
"syntaxOperator": {
"dark": "frappeSky",
"light": "frappeSky"
},
"syntaxPunctuation": {
"dark": "frappeText",
"light": "frappeText"
}
}
}
@@ -0,0 +1,249 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg": "#181818",
"darkPanel": "#141414",
"darkElement": "#262626",
"darkFg": "#e4e4e4",
"darkMuted": "#e4e4e45e",
"darkBorder": "#e4e4e413",
"darkBorderActive": "#e4e4e426",
"darkCyan": "#88c0d0",
"darkBlue": "#81a1c1",
"darkGreen": "#3fa266",
"darkGreenBright": "#70b489",
"darkRed": "#e34671",
"darkRedBright": "#fc6b83",
"darkYellow": "#f1b467",
"darkOrange": "#d2943e",
"darkPink": "#E394DC",
"darkPurple": "#AAA0FA",
"darkTeal": "#82D2CE",
"darkSyntaxYellow": "#F8C762",
"darkSyntaxOrange": "#EFB080",
"darkSyntaxGreen": "#A8CC7C",
"darkSyntaxBlue": "#87C3FF",
"lightBg": "#fcfcfc",
"lightPanel": "#f3f3f3",
"lightElement": "#ededed",
"lightFg": "#141414",
"lightMuted": "#141414ad",
"lightBorder": "#14141413",
"lightBorderActive": "#14141426",
"lightTeal": "#6f9ba6",
"lightBlue": "#3c7cab",
"lightBlueDark": "#206595",
"lightGreen": "#1f8a65",
"lightGreenBright": "#55a583",
"lightRed": "#cf2d56",
"lightRedBright": "#e75e78",
"lightOrange": "#db704b",
"lightYellow": "#c08532",
"lightPurple": "#9e94d5",
"lightPurpleDark": "#6049b3",
"lightPink": "#b8448b",
"lightMagenta": "#b3003f"
},
"theme": {
"primary": {
"dark": "darkCyan",
"light": "lightTeal"
},
"secondary": {
"dark": "darkBlue",
"light": "lightBlue"
},
"accent": {
"dark": "darkCyan",
"light": "lightTeal"
},
"error": {
"dark": "darkRed",
"light": "lightRed"
},
"warning": {
"dark": "darkYellow",
"light": "lightOrange"
},
"success": {
"dark": "darkGreen",
"light": "lightGreen"
},
"info": {
"dark": "darkBlue",
"light": "lightBlue"
},
"text": {
"dark": "darkFg",
"light": "lightFg"
},
"textMuted": {
"dark": "darkMuted",
"light": "lightMuted"
},
"background": {
"dark": "darkBg",
"light": "lightBg"
},
"backgroundPanel": {
"dark": "darkPanel",
"light": "lightPanel"
},
"backgroundElement": {
"dark": "darkElement",
"light": "lightElement"
},
"border": {
"dark": "darkBorder",
"light": "lightBorder"
},
"borderActive": {
"dark": "darkCyan",
"light": "lightTeal"
},
"borderSubtle": {
"dark": "#0f0f0f",
"light": "#e0e0e0"
},
"diffAdded": {
"dark": "darkGreen",
"light": "lightGreen"
},
"diffRemoved": {
"dark": "darkRed",
"light": "lightRed"
},
"diffContext": {
"dark": "darkMuted",
"light": "lightMuted"
},
"diffHunkHeader": {
"dark": "darkMuted",
"light": "lightMuted"
},
"diffHighlightAdded": {
"dark": "darkGreenBright",
"light": "lightGreenBright"
},
"diffHighlightRemoved": {
"dark": "darkRedBright",
"light": "lightRedBright"
},
"diffAddedBg": {
"dark": "#3fa26633",
"light": "#1f8a651f"
},
"diffRemovedBg": {
"dark": "#b8004933",
"light": "#cf2d5614"
},
"diffContextBg": {
"dark": "darkPanel",
"light": "lightPanel"
},
"diffLineNumber": {
"dark": "#e4e4e442",
"light": "#1414147a"
},
"diffAddedLineNumberBg": {
"dark": "#3fa26633",
"light": "#1f8a651f"
},
"diffRemovedLineNumberBg": {
"dark": "#b8004933",
"light": "#cf2d5614"
},
"markdownText": {
"dark": "darkFg",
"light": "lightFg"
},
"markdownHeading": {
"dark": "darkPurple",
"light": "lightBlueDark"
},
"markdownLink": {
"dark": "darkTeal",
"light": "lightBlueDark"
},
"markdownLinkText": {
"dark": "darkBlue",
"light": "lightMuted"
},
"markdownCode": {
"dark": "darkPink",
"light": "lightGreen"
},
"markdownBlockQuote": {
"dark": "darkMuted",
"light": "lightMuted"
},
"markdownEmph": {
"dark": "darkTeal",
"light": "lightFg"
},
"markdownStrong": {
"dark": "darkSyntaxYellow",
"light": "lightFg"
},
"markdownHorizontalRule": {
"dark": "darkMuted",
"light": "lightMuted"
},
"markdownListItem": {
"dark": "darkFg",
"light": "lightFg"
},
"markdownListEnumeration": {
"dark": "darkCyan",
"light": "lightMuted"
},
"markdownImage": {
"dark": "darkCyan",
"light": "lightBlueDark"
},
"markdownImageText": {
"dark": "darkBlue",
"light": "lightMuted"
},
"markdownCodeBlock": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxComment": {
"dark": "darkMuted",
"light": "lightMuted"
},
"syntaxKeyword": {
"dark": "darkTeal",
"light": "lightMagenta"
},
"syntaxFunction": {
"dark": "darkSyntaxOrange",
"light": "lightOrange"
},
"syntaxVariable": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxString": {
"dark": "darkPink",
"light": "lightPurple"
},
"syntaxNumber": {
"dark": "darkSyntaxYellow",
"light": "lightPink"
},
"syntaxType": {
"dark": "darkSyntaxOrange",
"light": "lightBlueDark"
},
"syntaxOperator": {
"dark": "darkFg",
"light": "lightFg"
},
"syntaxPunctuation": {
"dark": "darkFg",
"light": "lightFg"
}
}
}
+2 -1
View File
@@ -32,7 +32,8 @@ export function FormatError(input: unknown) {
}
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""),
`Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
(input.data.message ? `: ${input.data.message}` : ""),
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
+6
View File
@@ -666,6 +666,12 @@ export namespace Config {
.string()
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
default_agent: z
.string()
.optional()
.describe(
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
),
username: z
.string()
.optional()
@@ -424,6 +424,10 @@ export namespace ProviderTransform {
result.required = result.required.filter((field: any) => field in result.properties)
}
if (result.type === "array" && result.items == null) {
result.items = {}
}
return result
}
+75 -2
View File
@@ -1060,11 +1060,11 @@ export namespace Server {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msgs = await Session.messages({ sessionID })
let currentAgent = "build"
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || "build"
currentAgent = info.agent || (await Agent.defaultAgent())
break
}
}
@@ -1188,6 +1188,79 @@ export namespace Server {
return c.json(message)
},
)
.delete(
"/session/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Delete a part from a message",
operationId: "part.delete",
responses: {
200: {
description: "Successfully deleted part",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
await Session.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
return c.json(true)
},
)
.patch(
"/session/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Update a part in a message",
operationId: "part.update",
responses: {
200: {
description: "Successfully updated part",
content: {
"application/json": {
schema: resolver(MessageV2.Part),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
validator("json", MessageV2.Part),
async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) {
throw new Error(
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await Session.updatePart(body)
return c.json(part)
},
)
.post(
"/session/:sessionID/message",
describeRoute({
+17
View File
@@ -339,6 +339,23 @@ export namespace Session {
},
)
export const removePart = fn(
z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part"),
}),
async (input) => {
await Storage.remove(["part", input.messageID, input.partID])
Bus.publish(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
return input.partID
},
)
const UpdatePartInput = z.union([
MessageV2.Part,
z.object({
+3 -3
View File
@@ -715,7 +715,7 @@ export namespace SessionPrompt {
}
async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? "build")
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -1282,7 +1282,7 @@ export namespace SessionPrompt {
export async function command(input: CommandInput) {
log.info("command", input)
const command = await Command.get(input.command)
const agentName = command.agent ?? input.agent ?? "build"
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
@@ -1425,7 +1425,7 @@ export namespace SessionPrompt {
time: {
created: Date.now(),
},
agent: input.message.info.role === "user" ? input.message.info.agent : "build",
agent: input.message.info.role === "user" ? input.message.info.agent : await Agent.defaultAgent(),
model: {
providerID: input.providerID,
modelID: input.modelID,
+146
View File
@@ -0,0 +1,146 @@
import { test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
test("loads built-in agents when no custom agents configured", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
},
})
})
test("custom subagent works alongside built-in primary agents", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "helper.md"),
`---
model: test/model
mode: subagent
---
Helper subagent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const helper = agents.find((a) => a.name === "helper")
expect(helper).toBeDefined()
expect(helper?.mode).toBe("subagent")
// Built-in primary agents should still exist
const build = agents.find((a) => a.name === "build")
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
},
})
})
test("throws error when all primary agents are disabled", 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",
agent: {
build: { disable: true },
plan: { disable: true },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
try {
await Agent.list()
expect(true).toBe(false) // should not reach here
} catch (e: any) {
expect(e.data?.message).toContain("No primary agents are available")
}
},
})
})
test("does not throw when at least one primary agent remains", 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",
agent: {
build: { disable: true },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const plan = agents.find((a) => a.name === "plan")
expect(plan).toBeDefined()
expect(plan?.mode).toBe("primary")
},
})
})
test("custom primary agent satisfies requirement when built-ins disabled", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "custom.md"),
`---
model: test/model
mode: primary
---
Custom primary agent`,
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
build: { disable: true },
plan: { disable: true },
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agents = await Agent.list()
const custom = agents.find((a) => a.name === "custom")
expect(custom).toBeDefined()
expect(custom?.mode).toBe("primary")
},
})
})
@@ -450,6 +450,38 @@ test("merges plugin arrays from global and local configs", async () => {
})
})
test("does not error when only custom agent is a subagent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "helper.md"),
`---
model: test/model
mode: subagent
---
Helper subagent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["helper"]).toEqual({
name: "helper",
model: "test/model",
mode: "subagent",
prompt: "Helper subagent prompt",
})
},
})
})
test("deduplicates duplicate plugins from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -167,6 +167,30 @@ describe("ProviderTransform.maxOutputTokens", () => {
})
})
describe("ProviderTransform.schema - gemini array items", () => {
test("adds missing items for array properties", () => {
const geminiModel = {
providerID: "google",
api: {
id: "gemini-3-pro",
},
} as any
const schema = {
type: "object",
properties: {
nodes: { type: "array" },
edges: { type: "array", items: { type: "string" } },
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.nodes.items).toBeDefined()
expect(result.properties.edges.items.type).toBe("string")
})
})
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
const msgs = [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+80
View File
@@ -47,6 +47,11 @@ import type {
McpLocalConfig,
McpRemoteConfig,
McpStatusResponses,
Part as Part2,
PartDeleteErrors,
PartDeleteResponses,
PartUpdateErrors,
PartUpdateResponses,
PathGetResponses,
PermissionRespondErrors,
PermissionRespondResponses,
@@ -1486,6 +1491,79 @@ export class Session extends HeyApiClient {
}
}
export class Part extends HeyApiClient {
/**
* Delete a part from a message
*/
public delete<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
messageID: string
partID: string
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
{ in: "path", key: "partID" },
{ in: "query", key: "directory" },
],
},
],
)
return (options?.client ?? this.client).delete<PartDeleteResponses, PartDeleteErrors, ThrowOnError>({
url: "/session/{sessionID}/message/{messageID}/part/{partID}",
...options,
...params,
})
}
/**
* Update a part in a message
*/
public update<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
messageID: string
partID: string
directory?: string
part?: Part2
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
{ in: "path", key: "partID" },
{ in: "query", key: "directory" },
{ key: "part", map: "body" },
],
},
],
)
return (options?.client ?? this.client).patch<PartUpdateResponses, PartUpdateErrors, ThrowOnError>({
url: "/session/{sessionID}/message/{messageID}/part/{partID}",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Permission extends HeyApiClient {
/**
* Respond to permission
@@ -2588,6 +2666,8 @@ export class OpencodeClient extends HeyApiClient {
session = new Session({ client: this.client })
part = new Part({ client: this.client })
permission = new Permission({ client: this.client })
command = new Command({ client: this.client })
+93
View File
@@ -1414,6 +1414,10 @@ export type Config = {
* Small model to use for tasks like title generation in the format of provider/model
*/
small_model?: string
/**
* Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.
*/
default_agent?: string
/**
* Custom username to display in conversations instead of system username
*/
@@ -1767,6 +1771,7 @@ export type Agent = {
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
default?: boolean
topP?: number
temperature?: number
color?: string
@@ -2915,6 +2920,94 @@ export type SessionMessageResponses = {
export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]
export type PartDeleteData = {
body?: never
path: {
/**
* Session ID
*/
sessionID: string
/**
* Message ID
*/
messageID: string
/**
* Part ID
*/
partID: string
}
query?: {
directory?: string
}
url: "/session/{sessionID}/message/{messageID}/part/{partID}"
}
export type PartDeleteErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors]
export type PartDeleteResponses = {
/**
* Successfully deleted part
*/
200: boolean
}
export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses]
export type PartUpdateData = {
body?: Part
path: {
/**
* Session ID
*/
sessionID: string
/**
* Message ID
*/
messageID: string
/**
* Part ID
*/
partID: string
}
query?: {
directory?: string
}
url: "/session/{sessionID}/message/{messageID}/part/{partID}"
}
export type PartUpdateErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors]
export type PartUpdateResponses = {
/**
* Successfully updated part
*/
200: Part
}
export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses]
export type SessionPromptAsyncData = {
body?: {
messageID?: string
+174
View File
@@ -2126,6 +2126,173 @@
]
}
},
"/session/{sessionID}/message/{messageID}/part/{partID}": {
"delete": {
"operationId": "part.delete",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "sessionID",
"schema": {
"type": "string"
},
"required": true,
"description": "Session ID"
},
{
"in": "path",
"name": "messageID",
"schema": {
"type": "string"
},
"required": true,
"description": "Message ID"
},
{
"in": "path",
"name": "partID",
"schema": {
"type": "string"
},
"required": true,
"description": "Part ID"
}
],
"description": "Delete a part from a message",
"responses": {
"200": {
"description": "Successfully deleted part",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})"
}
]
},
"patch": {
"operationId": "part.update",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "path",
"name": "sessionID",
"schema": {
"type": "string"
},
"required": true,
"description": "Session ID"
},
{
"in": "path",
"name": "messageID",
"schema": {
"type": "string"
},
"required": true,
"description": "Message ID"
},
{
"in": "path",
"name": "partID",
"schema": {
"type": "string"
},
"required": true,
"description": "Part ID"
}
],
"description": "Update a part in a message",
"responses": {
"200": {
"description": "Successfully updated part",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Part"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Part"
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})"
}
]
}
},
"/session/{sessionID}/prompt_async": {
"post": {
"operationId": "session.prompt_async",
@@ -7986,6 +8153,10 @@
"description": "Small model to use for tasks like title generation in the format of provider/model",
"type": "string"
},
"default_agent": {
"description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
"type": "string"
},
"username": {
"description": "Custom username to display in conversations instead of system username",
"type": "string"
@@ -8985,6 +9156,9 @@
"hidden": {
"type": "boolean"
},
"default": {
"type": "boolean"
},
"topP": {
"type": "number"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -14,7 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
<body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/tauri",
"private": true,
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+2 -1
View File
@@ -3,13 +3,14 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
import { Script } from "@opencode-ai/script"
const sidecarConfig = getCurrentSidecar()
const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh release download ${Bun.env.OPENCODE_RELEASE_TAG} --pattern ${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} --repo sst/opencode --skip-existing --dir ${dir}`
await $`gh release download v${Script.version} --pattern ${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} --repo sst/opencode --skip-existing --dir ${dir}`
if (sidecarConfig.assetExt === "tar.gz") {
await $`tar -xvzf ${dir}/${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} -C ${dir}`
+8 -1
View File
@@ -6,7 +6,7 @@ use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -97,6 +97,11 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")]
let (mut rx, child) = app
.shell()
@@ -104,6 +109,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["serve", &format!("--port={port}")])
.spawn()
.expect("Failed to spawn opencode");
@@ -120,6 +126,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args([
"-il",
"-c",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.175",
"version": "1.0.184",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
+10 -8
View File
@@ -463,13 +463,13 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
<Show when={props.output}>
{(output) => (
<div data-component="tool-output" data-scrollable>
<Markdown text={output()} />
</div>
)}
</Show>
{/* <Show when={false && props.output}> */}
{/* {(output) => ( */}
{/* <div data-component="tool-output" data-scrollable> */}
{/* <Markdown text={output()} /> */}
{/* </div> */}
{/* )} */}
{/* </Show> */}
</BasicTool>
)
},
@@ -603,7 +603,9 @@ ToolRegistry.register({
icon="checklist"
trigger={{
title: "To-dos",
subtitle: `${props.input.todos?.filter((t: any) => t.status === "completed").length}/${props.input.todos?.length}`,
subtitle: props.input.todos
? `${props.input.todos.filter((t: any) => t.status === "completed").length}/${props.input.todos.length}`
: "",
}}
>
<Show when={props.input.todos?.length}>
@@ -25,7 +25,7 @@ export interface SessionReviewProps {
export const SessionReview = (props: SessionReviewProps) => {
const diffComponent = useDiffComponent()
const [store, setStore] = createStore({
open: props.diffs.map((d) => d.file),
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
})
const handleChange = (open: string[]) => {
@@ -78,7 +78,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Accordion multiple value={store.open} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => (
<Accordion.Item forceMount value={diff.file} data-slot="session-review-accordion-item">
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
+1 -1
View File
@@ -499,7 +499,7 @@ export function SessionTurn(
)
return (
<Switch>
<Match when={response() && lastTextPart()?.id === last()?.id}>
<Match when={!summary() && response() && lastTextPart()?.id === last()?.id}>
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
+8 -1
View File
@@ -24,6 +24,13 @@ type Data = {
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: { data: Data; directory: string }) => {
return { store: props.data, directory: props.directory }
return {
get store() {
return props.data
},
get directory() {
return props.directory
},
}
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.175",
"version": "1.0.184",
"private": true,
"type": "module",
"exports": {
+6 -2
View File
@@ -1,9 +1,13 @@
export function base64Encode(value: string) {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
const bytes = new TextEncoder().encode(value)
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("")
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export function base64Decode(value: string) {
return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.175",
"version": "1.0.184",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+2
View File
@@ -331,6 +331,8 @@ If you dont specify a model, primary agents use the [model globally configure
}
```
The model ID in your OpenCode config uses the format `provider/model-id`. For example, if you're using [OpenCode Zen](/docs/zen), you would use `opencode/gpt-5.1-codex` for GPT 5.1 Codex.
---
### Tools
+17
View File
@@ -194,6 +194,23 @@ You can also define agents using markdown files in `~/.config/opencode/agent/` o
---
### Default agent
You can set the default agent using the `default_agent` option. This determines which agent is used when none is explicitly specified.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "plan"
}
```
The default agent must be a primary agent (not a subagent). This can be a built-in agent like `"build"` or `"plan"`, or a [custom agent](/docs/agents) you've defined. If the specified agent doesn't exist or is a subagent, OpenCode will fall back to `"build"` with a warning.
This setting applies across all interfaces: TUI, CLI (`opencode run`), desktop app, and GitHub Action.
---
### Sharing
You can configure the [share](/docs/share) feature through the `share` option.
+51
View File
@@ -81,6 +81,7 @@ Or you can set it up manually.
## Configuration
- `model`: The model to use with OpenCode. Takes the format of `provider/model`. This is **required**.
- `agent`: The agent to use. Must be a primary agent. Falls back to `default_agent` from config or `"build"` if not found.
- `share`: Whether to share the OpenCode session. Defaults to **true** for public repositories.
- `prompt`: Optional custom prompt to override the default behavior. Use this to customize how OpenCode processes requests.
- `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, OpenCode uses the installation access token from the OpenCode GitHub App, so commits, comments, and pull requests appear as coming from the app.
@@ -99,6 +100,56 @@ Or you can set it up manually.
---
## Supported Events
OpenCode can be triggered by the following GitHub events:
| Event Type | Triggered By | Details |
| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. |
| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. |
| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. |
### Schedule Example
Run OpenCode on a schedule to perform automated tasks:
```yaml title=".github/workflows/opencode-scheduled.yml"
name: Scheduled OpenCode Task
on:
schedule:
- cron: "0 9 * * 1" # Every Monday at 9am UTC
jobs:
opencode:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run OpenCode
uses: sst/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
model: anthropic/claude-sonnet-4-20250514
prompt: |
Review the codebase for any TODO comments and create a summary.
If you find issues worth addressing, open an issue to track them.
```
For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from.
> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run.
---
## Custom prompts
Override the default prompt to customize OpenCode's behavior for your workflow.
+3 -1
View File
@@ -60,7 +60,7 @@ OpenCode config.
}
```
Here the full ID is `provider_id/model_id`.
Here the full ID is `provider_id/model_id`. For example, if you're using [OpenCode Zen](/docs/zen), you would use `opencode/gpt-5.1-codex` for GPT 5.1 Codex.
If you've configured a [custom provider](/docs/providers#custom), the `provider_id` is key from the `provider` part of your config, and the `model_id` is the key from `provider.models`.
@@ -117,6 +117,7 @@ You can also define custom models that extend built-in ones and can optionally u
"models": {
"gpt-5-high": {
"id": "gpt-5",
"name": "MyGPT5 (High Reasoning)",
"options": {
"reasoningEffort": "high",
"textVerbosity": "low",
@@ -125,6 +126,7 @@ You can also define custom models that extend built-in ones and can optionally u
},
"gpt-5-low": {
"id": "gpt-5",
"name": "MyGPT5 (Low Reasoning)",
"options": {
"reasoningEffort": "low",
"textVerbosity": "low",
-4
View File
@@ -77,7 +77,6 @@ You can also access our models through the following API endpoints.
| 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 | `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` |
@@ -110,7 +109,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.1 | Free | Free | Free | - |
| Grok Code Fast 1 | Free | Free | Free | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
@@ -144,7 +142,6 @@ Credit card fees are passed along at cost; we don't charge anything beyond that.
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.
- MiniMax M2.1 is currently free on OpenCode for a limited time. The MiniMax team is using this time to collect feedback and improve M2.1.
- 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.
@@ -156,7 +153,6 @@ The free models:
All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions:
- Grok Code Fast 1: During its free period, collected data may be used to improve Grok Code.
- MiniMax M2.1: During its free period, collected data may be used to improve M2.1.
- 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).
+18
View File
@@ -0,0 +1,18 @@
import type { APIRoute } from "astro"
import { getCollection } from "astro:content"
export const GET: APIRoute = async ({ params }) => {
const slug = params.slug || "index"
const docs = await getCollection("docs")
const doc = docs.find((d) => d.id === slug)
if (!doc) {
return new Response("Not found", { status: 404 })
}
return new Response(doc.body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
})
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Script } from "@opencode-ai/script"
import { $ } from "bun"
if (!Script.preview) {
await $`gh release edit ${process.env.OPENCODE_RELEASE_TAG} --draft=false`
await $`gh release edit v${Script.version} --draft=false`
}
await $`bun install`
+8 -3
View File
@@ -181,6 +181,8 @@ await import(`../packages/plugin/script/publish.ts`)
const dir = new URL("..", import.meta.url).pathname
process.chdir(dir)
let output = `version=${Script.version}\n`
if (!Script.preview) {
await $`git commit -am "release: v${Script.version}"`
await $`git tag v${Script.version}`
@@ -190,7 +192,10 @@ if (!Script.preview) {
await new Promise((resolve) => setTimeout(resolve, 5_000))
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes ${notes.join("\n") || "No notable changes"} ./packages/opencode/dist/*.zip ./packages/opencode/dist/*.tar.gz`
const release = await $`gh release view v${Script.version} --json id,tagName`.json()
if (process.env.GITHUB_OUTPUT) {
await Bun.write(process.env.GITHUB_OUTPUT, `releaseId=${release.id}\ntagName=${release.tagName}\n`)
}
output += `release=${release.id}\n`
output += `tag=${release.tagName}\n`
}
if (process.env.GITHUB_OUTPUT) {
await Bun.write(process.env.GITHUB_OUTPUT, output)
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.175",
"version": "1.0.184",
"publisher": "sst-dev",
"repository": {
"type": "git",