Compare commits

...

68 Commits

Author SHA1 Message Date
opencode
6251231e41 release: v1.0.144 2025-12-11 05:48:13 +00:00
Dax Raad
578072bb8e use new share url 2025-12-11 00:43:00 -05:00
Dax Raad
231390cb7b ci 2025-12-11 00:23:06 -05:00
Dax Raad
5955d20539 remove 2025-12-11 00:21:40 -05:00
Dax Raad
4309c078fb domain 2025-12-11 00:11:40 -05:00
Dax Raad
d14462f7a7 fix 2025-12-10 23:19:28 -05:00
Dax Raad
a02223a310 sync 2025-12-10 23:19:28 -05:00
Ayush Walekar
d93c8c7604 docs: update doc sdk.mdx (#5315) 2025-12-10 21:56:23 -06:00
Aiden Cline
7eb509db14 ci: rm bash tool from opencode ci workflow, reduce risks 2025-12-10 21:45:46 -06:00
Dax Raad
f1b8707286 ignore 2025-12-10 22:36:57 -05:00
Dax Raad
9b05217471 ignore 2025-12-10 22:34:16 -05:00
Dax Raad
d88912abf0 global bus 2025-12-10 22:28:56 -05:00
GitHub Action
28c6320cd6 chore: format code 2025-12-11 03:22:51 +00:00
Dax Raad
13a77005f1 global.dispose 2025-12-10 22:22:16 -05:00
Dax Raad
530b75a92a ignore 2025-12-10 22:22:16 -05:00
Aiden Cline
7b4f852f33 ignore: tmp transform exclusion 2025-12-10 21:18:19 -06:00
Aiden Cline
439aebb4e9 tweak: correct thinkingLevel 2025-12-10 21:13:10 -06:00
Brendan Allan
6f5f73a74a desktop: exclude ts-dist from tsconfig sources 2025-12-11 09:51:35 +08:00
Brendan Allan
bd1f5f884e tauri: update icons 2025-12-11 09:49:00 +08:00
Koichi Nakayamada
499ad4f84b fix: light mode visibility for filepath in /undo diff files (#5352) 2025-12-10 19:38:14 -06:00
Jinhyeok Lee
01fd0d8209 docs(bash): clarify description parameter is required (#5353) 2025-12-10 19:37:38 -06:00
Seb Duerr
df55ad89ab Add Cerebras integration header with opencode identifier (#5354) 2025-12-10 19:36:58 -06:00
Dax Raad
fadeed1fa4 desktop: enable zoom hotkeys in Tauri app 2025-12-10 19:27:05 -05:00
Dax Raad
13611176b0 fix deploy 2025-12-10 19:11:49 -05:00
Jay V
92fa66d76f core: reposition OpenCode as open source multi-platform coding agent
docs: update main intro page to reflect open source positioning and multi-platform availability
2025-12-10 19:05:33 -05:00
Jay V
1a1874d8b3 docs: desktop 2025-12-10 18:43:19 -05:00
Adam
56540f8312 wip(desktop): progress 2025-12-10 17:31:13 -06:00
Shantur Rathore
89d51ad596 compaction: improve compaction prompt (#5348) 2025-12-10 17:21:38 -06:00
Adam
15b8c14542 fix: tauri 2025-12-10 17:19:50 -06:00
Adam
85cfa226c3 wip(desktop): progress 2025-12-10 17:17:37 -06:00
Christian Stewart
cbb591eb7d fix: more descriptive tool or subtask execution failed error (#5337)
Signed-off-by: Christian Stewart <christian@aperture.us>
2025-12-10 17:12:49 -06:00
Aiden Cline
e36c349222 tweak: oc -> OC 2025-12-10 17:06:16 -06:00
Christian Stewart
b274371dbb feat: use |- for intermediate sub-agent steps (#5336)
Signed-off-by: Christian Stewart <christian@aperture.us>
2025-12-10 16:36:11 -06:00
Hammad Shami
72eb004057 feat: add helicone docs + helicone session tracking (#5265) 2025-12-10 16:23:52 -06:00
Yukai Huang
e46080aa8c fix(auth): add plugin lookup for custom provider in 'Other' flow (#5324) 2025-12-10 16:23:12 -06:00
Aiden Cline
7d82f1769c tweak: small fix 2025-12-10 16:01:10 -06:00
OpeOginni
7435d94f85 fix(cli): obtain directory data from server (#5320) 2025-12-10 15:55:15 -06:00
Github Action
e060f968f5 Update Nix flake.lock and hashes 2025-12-10 21:18:57 +00:00
Dax Raad
86f7cc17ae tui: pass dynamic port to frontend
Frontend now receives the server port via window.__OPENCODE__.port,
allowing it to connect when using a random free port instead of hardcoded 4096
2025-12-10 16:17:36 -05:00
Adam
58e66dd3d1 wip(desktop): progress 2025-12-10 15:17:04 -06:00
Adam
190fa4c87a wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam
91d743ef9a wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam
804ad5897f wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam
f20d6e8555 wip(desktop): progress 2025-12-10 15:17:03 -06:00
Adam
e694d4d880 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam
ada40decd1 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam
6866a060bc wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam
a4ec619c74 wip(desktop): progress 2025-12-10 15:17:02 -06:00
Adam
67a95c3cc8 wip(desktop): progress 2025-12-10 15:17:01 -06:00
Dax Raad
8d3eac2347 fix type 2025-12-10 16:14:32 -05:00
Dax Raad
9ad828dcd0 tui: use random free port and enable icon discovery by default
- Tauri app now automatically finds an available port instead of defaulting to 4096
- Icon discovery feature is now enabled by default in the Tauri app
- Prevents port conflicts when multiple OpenCode instances are running
2025-12-10 16:13:11 -05:00
Aiden Cline
59fb3ae606 ignore: add bash tests 2025-12-10 15:07:40 -06:00
GitHub Action
0ab3b88250 chore: format code 2025-12-10 21:01:06 +00:00
Dax Raad
a1175bddcd gen types 2025-12-10 15:59:42 -05:00
Dax Raad
936a6be5d6 stuff adam needs 2025-12-10 15:59:42 -05:00
igordertigor
03c6c3f4cb docs: document accept always behavior (#5340)
Co-authored-by: Ingo Fruend <ingo@oudyo.com>
2025-12-10 14:59:12 -06:00
Aiden Cline
6288a032fd bump bun to 1.3.4 2025-12-10 14:48:52 -06:00
Daniel Polito
31e6ed6806 Add Eyes Reaction instead of Comment Working on Github Action (#5072) 2025-12-10 13:35:55 -06:00
Aiden Cline
da56319af4 ignore: fix test 2025-12-10 13:31:45 -06:00
GitHub Action
2198f9400f chore: format code 2025-12-10 19:31:11 +00:00
Dax Raad
ffc4d53923 add models.dev family 2025-12-10 14:30:28 -05:00
Aiden Cline
18d3c054a3 more interleaved thinking fixes (#5334) 2025-12-10 13:29:26 -06:00
Aiden Cline
59c5da9b6c tweak: set gemini thinkingLevel to high by default 2025-12-10 13:09:27 -06:00
Github Action
15880195a2 Update Nix flake.lock and hashes 2025-12-10 19:04:03 +00:00
Tom Aylott
117de64f39 fix: upgrade OpenRouter sdk pkg (#5302)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-10 13:02:26 -06:00
Dax Raad
388156704a fix /provider endpoint to return loaded providers 2025-12-10 13:49:45 -05:00
GitHub Action
faf443132f chore: format code 2025-12-10 18:32:21 +00:00
Nick
36a9be040b docs: add opencode-type-inject to ecosystem plugins (#5331) 2025-12-10 12:31:44 -06:00
147 changed files with 2049 additions and 1342 deletions

View File

@@ -29,5 +29,6 @@ jobs:
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
with:
model: opencode/claude-haiku-4-5

View File

@@ -7,7 +7,7 @@
</picture>
</a>
</p>
<p align="center">The AI coding agent built for the terminal.</p>
<p align="center">The open source 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>

View File

@@ -20,7 +20,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.143",
"version": "1.0.144",
"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.143",
"version": "1.0.144",
"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.143",
"version": "1.0.144",
"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.143",
"version": "1.0.144",
"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.143",
"version": "1.0.144",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -168,7 +168,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -196,7 +196,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -212,7 +212,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.143",
"version": "1.0.144",
"bin": {
"opencode": "./bin/opencode",
},
@@ -241,7 +241,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.8",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.60",
"@opentui/solid": "0.1.60",
"@parcel/watcher": "2.5.1",
@@ -304,7 +304,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -324,7 +324,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.143",
"version": "1.0.144",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -335,7 +335,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -348,7 +348,7 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@opencode-ai/desktop": "workspace:*",
"@tauri-apps/api": "^2",
@@ -370,7 +370,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -402,7 +402,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"zod": "catalog:",
},
@@ -413,7 +413,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.143",
"version": "1.0.144",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -462,7 +462,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.0-beta.10",
"@pierre/precision-diffs": "0.6.1",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
@@ -1141,7 +1141,7 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.8", "", { "dependencies": { "@openrouter/sdk": "^0.1.8" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-pQT8AzZBKg9f4bkt4doF486ZlhK0XjKkevrLkiqYgfh1Jplovieu28nK4Y+xy3sF18/mxjqh9/2y6jh01qzLrA=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
@@ -1277,7 +1277,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.0-beta.10", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-2rdd1Q1xJbB0Z4oUbm0Ybrr2gLFEdvNetZLadJboZSFL7Q4gFujdQZfXfV3vB9X+esjt++v0nzb3mioW25BOTA=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.1", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],

View File

@@ -1,10 +1,10 @@
import { SECRET } from "./secret"
import { domain } from "./stage"
import { domain, shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const enterprise = new sst.cloudflare.x.SolidStart("Enterprise", {
domain: "enterprise." + domain,
const teams = new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",
environment: {

View File

@@ -11,3 +11,9 @@ new cloudflare.RegionalHostname("RegionalHostname", {
regionKey: "us",
zoneId: zoneID,
})
export const shortDomain = (() => {
if ($app.stage === "production") return "opncd.ai"
if ($app.stage === "dev") return "dev.opncd.ai"
return `${$app.stage}.dev.opncd.ai`
})()

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-pwjePZClFKDhDr5xrTNCBlO0xTwxLQYiQYaIEd0FdQQ="
"nodeModules": "sha256-WQMQmqKojxdRtwv6KL9HBaDfwYa4qPn2pvXKqgNM73A="
}

View File

@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.3",
"packageManager": "bun@1.3.4",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
@@ -30,7 +30,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.0-beta.10",
"@pierre/precision-diffs": "0.6.1",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.143",
"version": "1.0.144",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View File

@@ -13,7 +13,7 @@ export default function App() {
root={(props) => (
<MetaProvider>
<Title>opencode</Title>
<Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
<Meta name="description" content="OpenCode - The open source coding agent." />
<Favicon />
<Suspense>{props.children}</Suspense>
</MetaProvider>

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "35K",
full: "35,000",
compact: "38K",
full: "38,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "350",
commits: "5,000",
contributors: "375",
commits: "5,250",
monthlyUsers: "400,000",
},
} as const

View File

@@ -157,15 +157,9 @@ export default function Home() {
<section data-component="what">
<div data-slot="section-title">
<h3>What is OpenCode?</h3>
<p>OpenCode is an open source agent that helps you write and run code directly from the terminal.</p>
<p>OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.</p>
</div>
<ul>
<li>
<span>[*]</span>
<div>
<strong>Native TUI</strong> A responsive, native, themeable terminal UI
</div>
</li>
<li>
<span>[*]</span>
<div>
@@ -199,7 +193,7 @@ export default function Home() {
<li>
<span>[*]</span>
<div>
<strong>Any editor</strong> OpenCode runs in your terminal, pair it with any IDE
<strong>Any editor</strong> Available as a terminal interface, desktop app, and IDE extension
</div>
</li>
</ul>
@@ -651,9 +645,8 @@ export default function Home() {
<ul>
<li>
<Faq question="What is OpenCode?">
OpenCode is an open source agent that helps you write and run code directly from the terminal. You can
pair OpenCode with any AI model, and because its terminal-based you can pair it with your preferred
code editor.
OpenCode is an open source agent that helps you write and run code with any AI model. It's available
as a terminal-based interface, desktop app, or IDE extension.
</Faq>
</li>
<li>
@@ -674,7 +667,7 @@ export default function Home() {
</li>
<li>
<Faq question="Can I only use OpenCode in the terminal?">
Yes, for now. We are actively working on a desktop app. Join the waitlist for early access.
Not anymore! OpenCode is now available as an app for your desktop.
</Faq>
</li>
<li>

View File

@@ -0,0 +1,20 @@
import type { APIEvent } from "@solidjs/start/server"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
const url = new URL(req.url)
const targetUrl = `https://enterprise.opencode.ai/${url.pathname}${url.search}`
const response = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body: req.body,
})
return response
}
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
export const OPTIONS = handler
export const PATCH = handler

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.143",
"version": "1.0.144",
"description": "",
"type": "module",
"exports": {

View File

@@ -15,8 +15,14 @@ import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { Show } from "solid-js"
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
}
}
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url =
new URLSearchParams(document.location.search).get("url") ||

View File

@@ -3,7 +3,6 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { DateTime } from "luxon"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
@@ -14,10 +13,17 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Select } from "@opencode-ai/ui/select"
import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { useLayout } from "@/context/layout"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List, ListRef } from "@opencode-ai/ui/list"
import { iife } from "@opencode-ai/util/iife"
import { Input } from "@opencode-ai/ui/input"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
interface PromptInputProps {
class?: string
@@ -58,6 +64,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sync = useSync()
const local = useLocal()
const session = useSession()
const layout = useLayout()
const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@@ -455,55 +463,180 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize"
variant="ghost"
/>
<SelectDialog
title="Select model"
placeholder="Search models"
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
sortGroupsBy={(a, b) => {
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
return order.indexOf(aProvider) - order.indexOf(bProvider)
}}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
}
trigger={
<Button as="div" variant="ghost">
{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>
}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
<ProviderIcon name={i.provider.id as IconName} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={false}>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
</Show>
</div>
</div>
<Show when={!i.cost || i.cost?.input === 0}>
<div class="overflow-hidden text-12-medium text-text-strong">Free</div>
</Show>
</div>
)}
</SelectDialog>
<Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
{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>
<Show when={layout.dialog.opened() === "model"}>
<Switch>
<Match when={providers().connected().length > 0}>
<SelectDialog
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
title="Select model"
placeholder="Search models"
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
}
actions={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => layout.dialog.open("provider")}
>
Connect provider
</Button>
}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Show when={!i.cost || i.cost?.input === 0}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</SelectDialog>
</Match>
<Match when={true}>
{iife(() => {
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
return (
<Dialog
modal
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
>
<Dialog.Header>
<Dialog.Title>Select model</Dialog.Title>
<Dialog.CloseButton tabIndex={-1} />
</Dialog.Header>
<Dialog.Body>
<Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
<div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
<List
ref={(ref) => (listRef = ref)}
items={local.model.list()}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
layout.dialog.close("model")
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>Free</Tag>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<div />
<div />
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6">
<div class="px-2 text-14-medium text-text-base">
Add more models from popular providers
</div>
<List
class="w-full"
key={(x) => x?.id}
items={providers().popular()}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
layout.dialog.close("model")
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">
Connect with Claude Pro/Max or API key
</div>
</Show>
</div>
)}
</List>
<Button variant="ghost" class="w-full justify-start">
<div class="flex items-center gap-2">
<Icon name="plus-small" />
<div class="text-text-strong">View all providers</div>
</div>
</Button>
</div>
</div>
</div>
</Dialog.Body>
</Dialog>
)
})}
</Match>
</Switch>
</Show>
</div>
<Tooltip
placement="top"

View File

@@ -1,7 +1,6 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
@@ -12,49 +11,18 @@ import type {
FileDiff,
Todo,
SessionStatus,
ProviderListResponse,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
const PASTEL_COLORS = [
"#FCEAFD", // pastel pink
"#FFDFBA", // pastel peach
"#FFFFBA", // pastel yellow
"#BAFFC9", // pastel green
"#EAF6FD", // pastel blue
"#EFEAFD", // pastel lavender
"#FEC8D8", // pastel rose
"#D4F0F0", // pastel cyan
"#FDF0EA", // pastel coral
"#C1E1C1", // pastel mint
]
function pickAvailableColor(usedColors: Set<string>) {
const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
return available[Math.floor(Math.random() * available.length)]
}
async function ensureProjectColor(
project: Project,
sdk: ReturnType<typeof useGlobalSDK>,
usedColors: Set<string>,
): Promise<Project> {
if (project.icon?.color) return project
const color = pickAvailableColor(usedColors)
usedColors.add(color)
const updated = { ...project, icon: { ...project.icon, color } }
sdk.client.project.update({ projectID: project.id, icon: { color } })
return updated
}
type State = {
ready: boolean
provider: Provider[]
agent: Agent[]
project: string
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
@@ -81,13 +49,16 @@ type State = {
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
const sdk = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
projects: Project[]
project: Project[]
provider: ProviderListResponse
children: Record<string, State>
}>({
ready: false,
projects: [],
project: [],
provider: { all: [], connected: [], default: {} },
children: {},
})
@@ -96,11 +67,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
if (!children[directory]) {
setGlobalStore("children", directory, {
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
provider: [],
session: [],
session_status: {},
session_diff: {},
@@ -116,7 +87,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return children[directory]
}
const sdk = useGlobalSDK()
sdk.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -124,20 +94,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
if (directory === "global") {
switch (event.type) {
case "project.updated": {
const usedColors = new Set(globalStore.projects.map((p) => p.icon?.color).filter(Boolean) as string[])
ensureProjectColor(event.properties, sdk, usedColors).then((project) => {
const result = Binary.search(globalStore.projects, project.id, (s) => s.id)
if (result.found) {
setGlobalStore("projects", result.index, reconcile(project))
return
}
setGlobalStore(
"projects",
produce((draft) => {
draft.splice(result.index, 0, project)
}),
)
})
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
if (result.found) {
setGlobalStore("project", result.index, reconcile(event.properties))
return
}
setGlobalStore(
"project",
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
}
@@ -216,14 +183,16 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
Promise.all([
sdk.client.project.list().then(async (x) => {
const filtered = x.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
const usedColors = new Set(filtered.map((p) => p.icon?.color).filter(Boolean) as string[])
const projects = await Promise.all(filtered.map((p) => ensureProjectColor(p, sdk, usedColors)))
setGlobalStore(
"projects",
projects.sort((a, b) => a.id.localeCompare(b.id)),
"project",
x
.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
.sort((a, b) => a.id.localeCompare(b.id)),
)
}),
sdk.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
}),
]).then(() => setGlobalStore("ready", true))
return {

View File

@@ -4,6 +4,20 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { Project } from "@opencode-ai/sdk/v2"
const PASTEL_COLORS = [
"#FCEAFD", // pastel pink
"#FFDFBA", // pastel peach
"#FFFFBA", // pastel yellow
"#BAFFC9", // pastel green
"#EAF6FD", // pastel blue
"#EFEAFD", // pastel lavender
"#FEC8D8", // pastel rose
"#D4F0F0", // pastel cyan
"#FDF0EA", // pastel coral
"#C1E1C1", // pastel mint
]
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
@@ -26,9 +40,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
name: "default-layout.v6",
name: "default-layout.v7",
},
)
const [ephemeral, setEphemeral] = createStore({
dialog: {
open: undefined as undefined | "provider" | "model",
},
})
const usedColors = new Set<string>()
function pickAvailableColor() {
const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
return available[Math.floor(Math.random() * available.length)]
}
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
if (!metadata) return []
return [
{
...project,
...metadata,
},
]
}
function colorize(project: Project & { expanded: boolean }) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
return project
}
const enriched = createMemo(() => store.projects.flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
async function loadProjectSessions(directory: string) {
const [, setStore] = globalSync.child(directory)
@@ -43,30 +92,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
onMount(() => {
Promise.all(
store.projects.map(({ worktree }) => {
return loadProjectSessions(worktree)
store.projects.map((project) => {
return loadProjectSessions(project.worktree)
}),
)
})
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree)
if (!metadata) return []
return [
{
...project,
...metadata,
},
]
}
return {
projects: {
list: createMemo(() => store.projects.flatMap(enrich)),
list,
open(directory: string) {
if (store.projects.find((x) => x.worktree === directory)) return
loadProjectSessions(directory)
setStore("projects", (x) => [...x, { worktree: directory, expanded: true }])
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
},
close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
@@ -129,6 +167,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
dialog: {
opened: createMemo(() => ephemeral.dialog?.open),
open(dialog: "provider" | "model") {
setEphemeral("dialog", "open", dialog)
},
close(dialog: "provider" | "model") {
if (ephemeral.dialog?.open === dialog) {
setEphemeral("dialog", "open", undefined)
}
},
},
}
},
})

View File

@@ -25,6 +25,7 @@ export type View = LocalFile["view"]
export type LocalModel = Omit<Model, "provider"> & {
provider: Provider
latest?: boolean
}
export type ModelKey = { providerID: string; modelID: string }
@@ -38,8 +39,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sync = useSync()
function isModelValid(model: ModelKey) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
const provider = sync.data.provider?.all.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID] && sync.data.provider?.connected.includes(model.providerID)
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -114,7 +115,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
sync.data.provider.all
.filter((p) => sync.data.provider.connected.includes(p.id))
.flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
provider: p,
latest: m.name.includes("(latest)"),
})),
),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
@@ -134,12 +144,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return item
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
for (const p of sync.data.provider.connected) {
if (p in sync.data.provider.default) {
return {
providerID: p,
modelID: sync.data.provider.default[p],
}
}
}
throw new Error("No default model found")
})
const currentModel = createMemo(() => {

View File

@@ -94,7 +94,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
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] ?? []) : []))

View File

@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
@@ -42,8 +42,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.ready
},
get project() {
const match = Binary.search(globalSync.data.projects, store.project, (p) => p.id)
if (match.found) return globalSync.data.projects[match.index]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {

View File

@@ -0,0 +1,31 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
export function useProviders() {
const params = useParams()
const globalSync = useGlobalSync()
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const providers = createMemo(() => {
if (currentDirectory()) {
const [projectStore] = globalSync.child(currentDirectory())
return projectStore.provider
}
return globalSync.data.provider
})
const connected = createMemo(() =>
providers().all.filter(
(p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
return createMemo(() => ({
all: providers().all,
default: providers().default,
popular,
connected,
}))
}

View File

@@ -38,7 +38,7 @@ export default function Home() {
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<Switch>
<Match when={sync.data.projects.length > 0}>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div>
@@ -50,7 +50,7 @@ export default function Home() {
</div>
<ul class="flex flex-col gap-2">
<For
each={sync.data.projects
each={sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)}
>

View File

@@ -9,6 +9,7 @@ import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
@@ -29,6 +30,10 @@ import {
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Tag } from "@opencode-ai/ui/tag"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { popularProviders, useProviders } from "@/hooks/use-providers"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -44,6 +49,7 @@ export default function Layout(props: ParentProps) {
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const providers = useProviders()
function navigateToProject(directory: string | undefined) {
if (!directory) return
@@ -82,12 +88,21 @@ export default function Layout(props: ParentProps) {
}
}
async function connectProvider() {
layout.dialog.open("provider")
}
createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
setStore("lastSession", directory, params.id)
})
createEffect(() => {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
function getDraggableId(event: unknown): string | undefined {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
@@ -465,10 +480,44 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={!providers().connected().length && layout.sidebar.opened()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus-small"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
</Button>
</Tooltip>
</div>
</Match>
<Match when={true}>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="plus-small"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
</Button>
</Tooltip>
</Match>
</Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="folder-add-left"
@@ -481,7 +530,7 @@ export default function Layout(props: ParentProps) {
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
<Button
disabled
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="settings-gear"
@@ -494,7 +543,7 @@ export default function Layout(props: ParentProps) {
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
@@ -505,6 +554,59 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<Show when={layout.dialog.opened() === "provider"}>
<SelectDialog
defaultOpen
title="Connect provider"
placeholder="Search providers"
activeIcon="plus-small"
key={(x) => x?.id}
items={providers().all}
// current={local.model.current()}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
if (a.category === "Popular" && b.category !== "Popular") return -1
if (b.category === "Popular" && a.category !== "Popular") return 1
return 0
}}
// onSelect={(x) => }
onOpenChange={(open) => {
if (open) {
layout.dialog.open("provider")
} else {
layout.dialog.close("provider")
}
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</SelectDialog>
</Show>
</div>
</div>
)

View File

@@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"exclude": ["dist"]
"exclude": ["dist", "ts-dist"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.143",
"version": "1.0.144",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,3 @@
export default function () {
return <div>Hello World</div>
}

View File

@@ -212,7 +212,7 @@ export default function () {
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<ProviderIcon name={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
<ProviderIcon id={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">

View File

@@ -18,7 +18,14 @@ const nitroConfig: any = (() => {
})()
export default defineConfig({
plugins: [tailwindcss(), solidStart() as PluginOption, nitro(nitroConfig)],
plugins: [
tailwindcss(),
solidStart() as PluginOption,
nitro({
...nitroConfig,
baseURL: process.env.OPENCODE_BASE_URL,
}),
],
server: {
host: "0.0.0.0",
allowedHosts: true,

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.143"
version = "1.0.144"
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.143/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.144/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.143/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.144/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.143/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.144/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.143/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.144/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.143/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.144/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.143",
"version": "1.0.144",
"name": "opencode",
"type": "module",
"private": true,
@@ -70,7 +70,7 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.2.8",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.60",
"@opentui/solid": "0.1.60",
"@parcel/watcher": "2.5.1",

View File

@@ -7,7 +7,13 @@ import { GlobalBus } from "./global"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
const disposedEventType = "server.instance.disposed"
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
const state = Instance.state(
() => {
@@ -21,7 +27,7 @@ export namespace Bus {
const wildcard = entry.subscriptions.get("*")
if (!wildcard) return
const event = {
type: disposedEventType,
type: InstanceDisposed.type,
properties: {
directory: Instance.directory,
},
@@ -32,13 +38,6 @@ export namespace Bus {
},
)
export const InstanceDisposed = BusEvent.define(
disposedEventType,
z.object({
directory: z.string(),
}),
)
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,

View File

@@ -10,6 +10,154 @@ import { Config } from "../../config/config"
import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
type PluginAuth = NonNullable<Hooks["auth"]>
/**
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await new Promise((resolve) => setTimeout(resolve, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return true
}
if (method.type === "api") {
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
}
return false
}
export const AuthCommand = cmd({
command: "auth",
@@ -160,142 +308,8 @@ export const AuthLoginCommand = cmd({
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
let index = 0
if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await new Promise((resolve) => setTimeout(resolve, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
expires,
...extraFields,
})
}
if ("key" in result) {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
}
}
prompts.outro("Done")
return
}
if (method.type === "api") {
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return
}
}
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return
}
if (provider === "other") {
@@ -306,6 +320,14 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
provider = provider.replace(/^@ai-sdk\//, "")
if (prompts.isCancel(provider)) throw new UI.CancelledError()
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)

View File

@@ -403,12 +403,12 @@ export const GithubRunCommand = cmd({
let appToken: string
let octoRest: Octokit
let octoGraph: typeof graphql
let commentId: number
let gitConfig: string
let session: { id: string; title: string; version: string }
let shareId: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = payload.comment.id
try {
const actionToken = isMock ? args.token! : await getOidcToken()
@@ -422,8 +422,7 @@ export const GithubRunCommand = cmd({
await configureGit(appToken)
await assertPermissions()
const comment = await createComment()
commentId = comment.data.id
await addReaction("eyes")
// Setup opencode session
const repoData = await fetchRepo()
@@ -455,7 +454,8 @@ export const GithubRunCommand = cmd({
await pushToLocalBranch(summary, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction()
}
// Fork PR
else {
@@ -469,7 +469,8 @@ export const GithubRunCommand = cmd({
await pushToForkBranch(summary, prData, uncommittedChanges)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${shareBaseUrl}/s/${shareId}`))
await updateComment(`${response}${footer({ image: !hasShared })}`)
await createComment(`${response}${footer({ image: !hasShared })}`)
await removeReaction()
}
}
// Issue
@@ -489,9 +490,11 @@ export const GithubRunCommand = cmd({
summary,
`${response}\n\nCloses #${issueId}${footer({ image: true })}`,
)
await updateComment(`Created PR #${pr}${footer({ image: true })}`)
await createComment(`Created PR #${pr}${footer({ image: true })}`)
await removeReaction()
} else {
await updateComment(`${response}${footer({ image: true })}`)
await createComment(`${response}${footer({ image: true })}`)
await removeReaction()
}
}
} catch (e: any) {
@@ -503,7 +506,8 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
await updateComment(`${msg}${footer()}`)
await createComment(`${msg}${footer()}`)
await removeReaction()
core.setFailed(msg)
// Also output the clean error message for the action to capture
//core.setOutput("prepare_error", e.message);
@@ -931,24 +935,41 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
}
async function createComment() {
async function addReaction(reaction: "eyes") {
console.log("Adding reaction...")
return await octoRest.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
content: reaction,
})
}
async function removeReaction() {
console.log("Removing reaction...")
const reactions = await octoRest.rest.reactions.listForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
})
const eyesReaction = reactions.data.find((r) => r.content === "eyes")
if (!eyesReaction) return
await octoRest.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: triggerCommentId,
reaction_id: eyesReaction.id,
})
}
async function createComment(body: string) {
console.log("Creating comment...")
return await octoRest.rest.issues.createComment({
owner,
repo,
issue_number: issueId,
body: `[Working...](${runUrl})`,
})
}
async function updateComment(body: string) {
if (!commentId) return
console.log("Updating comment...")
return await octoRest.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
})
}
@@ -1029,7 +1050,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
const comments = (issue.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== commentId && id !== payload.comment.id
return id !== payload.comment.id
})
.map((c) => ` - ${c.author.login} at ${c.createdAt}: ${c.body}`)
@@ -1148,7 +1169,7 @@ query($owner: String!, $repo: String!, $number: Int!) {
const comments = (pr.comments?.nodes || [])
.filter((c) => {
const id = parseInt(c.databaseId)
return id !== commentId && id !== payload.comment.id
return id !== payload.comment.id
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)

View File

@@ -186,7 +186,7 @@ function App() {
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`oc | ${title}`)
renderer.setTerminalTitle(`OC | ${title}`)
}
})

View File

@@ -5,7 +5,8 @@ import { Global } from "@/global"
export function useDirectory() {
const sync = useSync()
return createMemo(() => {
const result = process.cwd().replace(Global.Path.home, "~")
const directory = sync.data.path.directory || process.cwd()
const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
})

View File

@@ -24,6 +24,7 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -62,6 +63,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
}>({
provider_next: {
all: [],
@@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
})
const sdk = useSDK()
@@ -286,6 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.client.path.get().then((x) => setStore("path", x.data!)),
]).then(() => {
setStore("status", "complete")
})

View File

@@ -894,7 +894,7 @@ export function Session() {
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text>
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
@@ -1503,11 +1503,15 @@ ToolRegistry.register<typeof TaskTool>({
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
{(task, index) => {
const summary = props.metadata.summary ?? []
return (
<text style={{ fg: task.state.status === "error" ? theme.error : theme.textMuted }}>
{index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "}
{task.state.status === "completed" ? task.state.title : ""}
</text>
)
}}
</For>
</box>
</Show>

View File

@@ -14,6 +14,8 @@ export namespace Flag {
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =

View File

@@ -3,6 +3,7 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
interface Context {
directory: string
@@ -52,6 +53,15 @@ export const Instance = {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
GlobalBus.emit("event", {
directory: Instance.directory,
payload: {
type: "server.instance.disposed",
properties: {
directory: Instance.directory,
},
},
})
},
async disposeAll() {
Log.Default.info("disposing all instances")

View File

@@ -107,7 +107,7 @@ export namespace Project {
await migrateFromGlobal(id, worktree)
}
}
if (Flag.OPENCODE_EXPERIMENTAL) discover(existing)
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
worktree,

View File

@@ -12,6 +12,7 @@ export namespace ModelsDev {
export const Model = z.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),

View File

@@ -318,6 +318,16 @@ export namespace Provider {
},
}
},
cerebras: async () => {
return {
autoload: false,
options: {
headers: {
"X-Cerebras-3rd-Party-Integration": "opencode",
},
},
}
},
}
export const Model = z
@@ -330,6 +340,7 @@ export namespace Provider {
npm: z.string(),
}),
name: z.string(),
family: z.string().optional(),
capabilities: z.object({
temperature: z.boolean(),
reasoning: z.boolean(),
@@ -407,6 +418,7 @@ export namespace Provider {
id: model.id,
providerID: provider.id,
name: model.name,
family: model.family,
api: {
id: model.id,
url: provider.api!,

View File

@@ -74,23 +74,28 @@ export namespace ProviderTransform {
return result
}
// DeepSeek: Handle reasoning_content for tool call continuations
// - With tool calls: Include reasoning_content in providerOptions so model can continue reasoning
// - Without tool calls: Strip reasoning (new turn doesn't need previous reasoning)
// See: https://api-docs.deepseek.com/guides/thinking_mode
if (model.providerID === "deepseek" || model.api.id.toLowerCase().includes("deepseek")) {
// TODO: rm later
const bugged =
(model.id === "kimi-k2-thinking" && model.providerID === "opencode") ||
(model.id === "moonshotai/Kimi-K2-Thinking" && model.providerID === "baseten")
if (
model.providerID === "deepseek" ||
model.api.id.toLowerCase().includes("deepseek") ||
(model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content" &&
!bugged)
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
const hasToolCalls = msg.content.some((part: any) => part.type === "tool-call")
const reasoningText = reasoningParts.map((part: any) => part.text).join("")
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// If this message has tool calls and reasoning, include reasoning_content
// so DeepSeek can continue reasoning after tool execution
if (hasToolCalls && reasoningText) {
// Include reasoning_content directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
content: filteredContent,
@@ -104,12 +109,12 @@ export namespace ProviderTransform {
}
}
// For final answers (no tool calls), just strip reasoning
return {
...msg,
content: filteredContent,
}
}
return msg
})
}
@@ -212,24 +217,30 @@ export namespace ProviderTransform {
): Record<string, any> {
const result: Record<string, any> = {}
// switch to providerID later, for now use this
if (model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = {
include: true,
}
if (model.api.id.includes("gemini-3")) {
result["reasoning"] = { effort: "high" }
}
}
if (model.providerID === "baseten") {
result["chat_template_args"] = { enable_thinking: true }
}
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
}
if (
model.providerID === "google" ||
(model.providerID.startsWith("opencode") && model.api.id.includes("gemini-3"))
) {
if (model.api.npm === "@ai-sdk/google" || model.api.npm === "@ai-sdk/google-vertex") {
result["thinkingConfig"] = {
includeThoughts: true,
}
if (model.api.id.includes("gemini-3")) {
result["thinkingConfig"]["thinkingLevel"] = "high"
}
}
if (model.api.id.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
@@ -273,23 +284,7 @@ export namespace ProviderTransform {
return options
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }, messages: ModelMessage[]) {
if (model.capabilities.interleaved && typeof model.capabilities.interleaved === "object") {
const cot = []
const assistantMessages = messages.filter((msg) => msg.role === "assistant")
for (const msg of assistantMessages) {
for (const part of msg.content) {
if (typeof part === "string") {
continue
}
if (part.type === "reasoning") {
cot.push(part)
}
}
}
options[model.capabilities.interleaved.field] = cot
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
switch (model.api.npm) {
case "@ai-sdk/openai":
case "@ai-sdk/azure":

View File

@@ -10,7 +10,7 @@ import { proxy } from "hono/proxy"
import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
import { mapValues } from "remeda"
import { filter, mapValues, sortBy, pipe } from "remeda"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
@@ -56,6 +56,7 @@ export namespace Server {
export const Event = {
Connected: BusEvent.define("server.connected", z.object({})),
Disposed: BusEvent.define("global.disposed", z.object({})),
}
const app = new Hono()
@@ -140,6 +141,35 @@ export namespace Server {
})
},
)
.post(
"/global/dispose",
describeRoute({
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
operationId: "global.dispose",
responses: {
200: {
description: "Global disposed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
return c.json(true)
},
)
.use(async (c, next) => {
const directory = c.req.query("directory") ?? c.req.header("x-opencode-directory") ?? process.cwd()
return Instance.provide({
@@ -483,6 +513,7 @@ export namespace Server {
schema: resolver(
z
.object({
home: z.string(),
state: z.string(),
config: z.string(),
worktree: z.string(),
@@ -499,6 +530,7 @@ export namespace Server {
}),
async (c) => {
return c.json({
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: Instance.worktree,
@@ -549,7 +581,11 @@ export namespace Server {
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list())
sessions.sort((a, b) => b.time.updated - a.time.updated)
pipe(
await Array.fromAsync(Session.list()),
filter((s) => !s.time.archived),
sortBy((s) => s.time.updated),
)
return c.json(sessions)
},
)
@@ -755,6 +791,9 @@ export namespace Server {
"json",
z.object({
title: z.string().optional(),
time: z.object({
archived: z.number().optional(),
}),
}),
),
async (c) => {
@@ -765,6 +804,7 @@ export namespace Server {
if (updates.title !== undefined) {
session.title = updates.title
}
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
})
return c.json(updatedSession)
@@ -1460,12 +1500,15 @@ export namespace Server {
}
}
const providers = mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x))
const connected = await Provider.list().then((x) => Object.keys(x))
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected,
connected: Object.keys(connected),
})
},
)

View File

@@ -143,7 +143,6 @@ export namespace SessionCompaction {
providerOptions: ProviderTransform.providerOptions(
model,
pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)),
[],
),
headers: model.headers,
abortSignal: input.abort,
@@ -175,7 +174,7 @@ export namespace SessionCompaction {
content: [
{
type: "text",
text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
text: "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.",
},
],
},

View File

@@ -60,6 +60,7 @@ export namespace Session {
created: z.number(),
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
}),
revert: z
.object({
@@ -222,34 +223,13 @@ export namespace Session {
if (cfg.share === "disabled") {
throw new Error("Sharing is disabled in configuration")
}
if (cfg.enterprise?.url) {
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
url: share.url,
}
})
}
const session = await get(id)
if (session.share) return session.share
const { Share } = await import("../share/share")
const share = await Share.create(id)
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
url: share.url,
}
})
await Storage.write(["share", id], share)
await Share.sync("session/info/" + id, session)
for (const msg of await messages({ sessionID: id })) {
await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
for (const part of msg.parts) {
await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part)
}
}
return share
})

View File

@@ -338,6 +338,7 @@ export namespace SessionPrompt {
},
},
})) as MessageV2.ToolPart
let executionError: Error | undefined
const result = await taskTool
.execute(
{
@@ -362,7 +363,11 @@ export namespace SessionPrompt {
},
},
)
.catch(() => {})
.catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage)
@@ -388,7 +393,7 @@ export namespace SessionPrompt {
...part,
state: {
status: "error",
error: "Tool execution failed",
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
time: {
start: part.state.status === "running" ? part.state.time.start : Date.now(),
end: Date.now(),
@@ -593,7 +598,7 @@ export namespace SessionPrompt {
OUTPUT_TOKEN_MAX,
),
abortSignal: abort,
providerOptions: ProviderTransform.providerOptions(model, params.options, messages),
providerOptions: ProviderTransform.providerOptions(model, params.options),
stopWhen: stepCountIs(1),
temperature: params.temperature,
topP: params.topP,
@@ -1473,7 +1478,7 @@ export namespace SessionPrompt {
await generateText({
// use higher # for reasoning models since reasoning tokens eat up a lot of the budget
maxOutputTokens: small.capabilities.reasoning ? 3000 : 20,
providerOptions: ProviderTransform.providerOptions(small, options, []),
providerOptions: ProviderTransform.providerOptions(small, options),
messages: [
...SystemPrompt.title(small.providerID).map(
(x): ModelMessage => ({

View File

@@ -91,7 +91,7 @@ export namespace SessionSummary {
if (textPart && !userMsg.summary?.title) {
const result = await generateText({
maxOutputTokens: small.capabilities.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small, options, []),
providerOptions: ProviderTransform.providerOptions(small, options),
messages: [
...SystemPrompt.title(small.providerID).map(
(x): ModelMessage => ({
@@ -144,7 +144,7 @@ export namespace SessionSummary {
const result = await generateText({
model: language,
maxOutputTokens: 100,
providerOptions: ProviderTransform.providerOptions(small, options, []),
providerOptions: ProviderTransform.providerOptions(small, options),
messages: [
...SystemPrompt.summarize(small.providerID).map(
(x): ModelMessage => ({

View File

@@ -11,9 +11,11 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext {
const log = Log.create({ service: "share-next" })
async function url() {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
}
export async function init() {
const config = await Config.get()
if (!config.enterprise) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
{
@@ -62,8 +64,7 @@ export namespace ShareNext {
export async function create(sessionID: string) {
log.info("creating share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const result = await fetch(`${url}/api/share`, {
const result = await fetch(`${await url()}/api/share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -126,11 +127,10 @@ export namespace ShareNext {
const queued = queue.get(sessionID)
if (!queued) return
queue.delete(sessionID)
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${url}/api/share/${share.id}/sync`, {
await fetch(`${await url()}/api/share/${share.id}/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -146,10 +146,9 @@ export namespace ShareNext {
export async function remove(sessionID: string) {
log.info("removing share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${url}/api/share/${share.id}`, {
await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",

View File

@@ -20,8 +20,7 @@ Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes).
If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command
does in 5-10 words.
- The description argument is required. You must write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being
returned to you.
- You can use the `run_in_background` parameter to run the command in the background,

View File

@@ -158,54 +158,6 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...")
})
test("DeepSeek without tool calls strips reasoning from content", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Let me think about this..." },
{ type: "text", text: "Final answer" },
],
},
] as any[]
const result = ProviderTransform.message(msgs, {
id: "deepseek/deepseek-chat",
providerID: "deepseek",
api: {
id: "deepseek-chat",
url: "https://api.deepseek.com",
npm: "@ai-sdk/openai-compatible",
},
name: "DeepSeek Chat",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
})
expect(result).toHaveLength(1)
expect(result[0].content).toEqual([{ type: "text", text: "Final answer" }])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined()
})
test("DeepSeek model ID containing 'deepseek' matches (case insensitive)", () => {
const msgs = [
{

View File

@@ -3,11 +3,12 @@ import path from "path"
import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Permission } from "../../src/permission"
import { tmpdir } from "../fixture/fixture"
const ctx = {
sessionID: "test",
messageID: "",
toolCallID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
@@ -33,23 +34,401 @@ describe("tool.bash", () => {
},
})
})
// TODO: better test
// test("cd ../ should ask for permission for external directory", async () => {
// await Instance.provide({
// directory: projectRoot,
// fn: async () => {
// bash.execute(
// {
// command: "cd ../",
// description: "Try to cd to parent directory",
// },
// ctx,
// )
// // Give time for permission to be asked
// await new Promise((resolve) => setTimeout(resolve, 1000))
// expect(Permission.pending()[ctx.sessionID]).toBeDefined()
// },
// })
// })
})
describe("tool.bash permissions", () => {
test("allows command matching allow pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"echo *": "allow",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const result = await bash.execute(
{
command: "echo hello",
description: "Echo hello",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
expect(result.metadata.output).toContain("hello")
},
})
})
test("denies command matching deny pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"curl *": "deny",
"*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "curl https://example.com",
description: "Fetch URL",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies all commands with wildcard deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "ls",
description: "List files",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("more specific pattern overrides general pattern", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"*": "deny",
"ls *": "allow",
"pwd*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// ls should be allowed
const result = await bash.execute(
{
command: "ls -la",
description: "List files",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
// pwd should be allowed
const pwd = await bash.execute(
{
command: "pwd",
description: "Print working directory",
},
ctx,
)
expect(pwd.metadata.exit).toBe(0)
// cat should be denied
await expect(
bash.execute(
{
command: "cat /etc/passwd",
description: "Read file",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies dangerous subcommands while allowing safe ones", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"find *": "allow",
"find * -delete*": "deny",
"find * -exec*": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Basic find should work
const result = await bash.execute(
{
command: "find . -name '*.ts'",
description: "Find typescript files",
},
ctx,
)
expect(result.metadata.exit).toBe(0)
// find -delete should be denied
await expect(
bash.execute(
{
command: "find . -name '*.tmp' -delete",
description: "Delete temp files",
},
ctx,
),
).rejects.toThrow("restricted")
// find -exec should be denied
await expect(
bash.execute(
{
command: "find . -name '*.ts' -exec cat {} \\;",
description: "Find and cat files",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("allows git read commands while denying writes", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"git status*": "allow",
"git log*": "allow",
"git diff*": "allow",
"git branch": "allow",
"git commit *": "deny",
"git push *": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// git status should work
const status = await bash.execute(
{
command: "git status",
description: "Git status",
},
ctx,
)
expect(status.metadata.exit).toBe(0)
// git log should work
const log = await bash.execute(
{
command: "git log --oneline -5",
description: "Git log",
},
ctx,
)
expect(log.metadata.exit).toBe(0)
// git commit should be denied
await expect(
bash.execute(
{
command: "git commit -m 'test'",
description: "Git commit",
},
ctx,
),
).rejects.toThrow("restricted")
// git push should be denied
await expect(
bash.execute(
{
command: "git push origin main",
description: "Git push",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
test("denies external directory access when permission is deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
bash: {
"*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// Should deny cd to parent directory (cd is checked for external paths)
await expect(
bash.execute(
{
command: "cd ../",
description: "Change to parent directory",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("denies workdir outside project when external_directory is deny", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
external_directory: "deny",
bash: {
"*": "allow",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
await expect(
bash.execute(
{
command: "ls",
workdir: "/tmp",
description: "List /tmp",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("handles multiple commands in sequence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
permission: {
bash: {
"echo *": "allow",
"curl *": "deny",
"*": "deny",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
// echo && echo should work
const result = await bash.execute(
{
command: "echo foo && echo bar",
description: "Echo twice",
},
ctx,
)
expect(result.metadata.output).toContain("foo")
expect(result.metadata.output).toContain("bar")
// echo && curl should fail (curl is denied)
await expect(
bash.execute(
{
command: "echo hi && curl https://example.com",
description: "Echo then curl",
},
ctx,
),
).rejects.toThrow("restricted")
},
})
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.143",
"version": "1.0.144",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.143",
"version": "1.0.144",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -29,4 +29,4 @@
"publishConfig": {
"directory": "dist"
}
}
}

View File

@@ -28,6 +28,7 @@ import type {
FindSymbolsResponses,
FindTextResponses,
FormatterStatusResponses,
GlobalDisposeResponses,
GlobalEventResponses,
InstanceDisposeResponses,
LspStatusResponses,
@@ -193,6 +194,18 @@ export class Global extends HeyApiClient {
...options,
})
}
/**
* Dispose instance
*
* Clean up and dispose all OpenCode instances, releasing all resources.
*/
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).post<GlobalDisposeResponses, unknown, ThrowOnError>({
url: "/global/dispose",
...options,
})
}
}
export class Project extends HeyApiClient {
@@ -812,6 +825,9 @@ export class Session extends HeyApiClient {
sessionID: string
directory?: string
title?: string
time?: {
archived?: number
}
},
options?: Options<never, ThrowOnError>,
) {
@@ -823,6 +839,7 @@ export class Session extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "body", key: "title" },
{ in: "body", key: "time" },
],
},
],

View File

@@ -575,6 +575,7 @@ export type Session = {
created: number
updated: number
compacting?: number
archived?: number
}
revert?: {
messageID: string
@@ -724,6 +725,13 @@ export type EventServerConnected = {
}
}
export type EventGlobalDisposed = {
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
@@ -758,6 +766,7 @@ export type Event =
| EventPtyExited
| EventPtyDeleted
| EventServerConnected
| EventGlobalDisposed
export type GlobalEvent = {
directory: string
@@ -1035,6 +1044,7 @@ export type ProviderConfig = {
[key: string]: {
id?: string
name?: string
family?: string
release_date?: string
attachment?: boolean
reasoning?: boolean
@@ -1390,6 +1400,7 @@ export type ToolListItem = {
export type ToolList = Array<ToolListItem>
export type Path = {
home: string
state: string
config: string
worktree: string
@@ -1461,6 +1472,7 @@ export type Model = {
npm: string
}
name: string
family?: string
capabilities: {
temperature: boolean
reasoning: boolean
@@ -1696,6 +1708,22 @@ export type GlobalEventResponses = {
export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]
export type GlobalDisposeData = {
body?: never
path?: never
query?: never
url: "/global/dispose"
}
export type GlobalDisposeResponses = {
/**
* Global disposed
*/
200: boolean
}
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
export type ProjectListData = {
body?: never
path?: never
@@ -2247,6 +2275,9 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
export type SessionUpdateData = {
body?: {
title?: string
time: {
archived?: number
}
}
path: {
sessionID: string
@@ -3027,6 +3058,7 @@ export type ProviderListResponses = {
[key: string]: {
id: string
name: string
family?: string
release_date: string
attachment: boolean
reasoning: boolean

View File

@@ -31,6 +31,31 @@
]
}
},
"/global/dispose": {
"post": {
"operationId": "global.dispose",
"summary": "Dispose instance",
"description": "Clean up and dispose all OpenCode instances, releasing all resources.",
"responses": {
"200": {
"description": "Global disposed",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.dispose({\n ...\n})"
}
]
}
},
"/project": {
"get": {
"operationId": "project.list",
@@ -1156,8 +1181,17 @@
"properties": {
"title": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
"archived": {
"type": "number"
}
}
}
}
},
"required": ["time"]
}
}
}
@@ -2788,6 +2822,9 @@
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"release_date": {
"type": "string"
},
@@ -6379,6 +6416,9 @@
},
"compacting": {
"type": "number"
},
"archived": {
"type": "number"
}
},
"required": ["created", "updated"]
@@ -6795,6 +6835,20 @@
},
"required": ["type", "properties"]
},
"Event.global.disposed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "global.disposed"
},
"properties": {
"type": "object",
"properties": {}
}
},
"required": ["type", "properties"]
},
"Event": {
"anyOf": [
{
@@ -6895,6 +6949,9 @@
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
{
"$ref": "#/components/schemas/Event.global.disposed"
}
]
},
@@ -7300,6 +7357,9 @@
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"release_date": {
"type": "string"
},
@@ -8089,6 +8149,9 @@
"Path": {
"type": "object",
"properties": {
"home": {
"type": "string"
},
"state": {
"type": "string"
},
@@ -8102,7 +8165,7 @@
"type": "string"
}
},
"required": ["state", "config", "worktree", "directory"]
"required": ["home", "state", "config", "worktree", "directory"]
},
"VcsInfo": {
"type": "object",
@@ -8287,6 +8350,9 @@
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"capabilities": {
"type": "object",
"properties": {

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/tauri",
"private": true,
"version": "1.0.143",
"version": "1.0.144",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",

View File

@@ -7,6 +7,7 @@
"core:default",
"opener:default",
"core:window:allow-start-dragging",
"core:webview:allow-set-webview-zoom",
"shell:default",
"updater:default",
"dialog:default",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 B

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 940 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1022 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 B

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 797 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 B

After

Width:  |  Height:  |  Size: 2.6 KiB

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