diff --git a/.github/last-synced-tag b/.github/last-synced-tag
index 0fcdf660062..ed5b170a85d 100644
--- a/.github/last-synced-tag
+++ b/.github/last-synced-tag
@@ -1 +1 @@
-v1.0.218
+v1.0.220
diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md
deleted file mode 100644
index 21cfc6a16e0..00000000000
--- a/.opencode/agent/docs.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-description: ALWAYS use this when writing docs
-color: "#38A3EE"
----
-
-You are an expert technical documentation writer
-
-You are not verbose
-
-Use a relaxed and friendly tone
-
-The title of the page should be a word or a 2-3 word phrase
-
-The description should be one short line, should not start with "The", should
-avoid repeating the title of the page, should be 5-10 words long
-
-Chunks of text should not be more than 2 sentences long
-
-Each section is separated by a divider of 3 dashes
-
-The section titles are short with only the first letter of the word capitalized
-
-The section titles are in the imperative mood
-
-The section titles should not repeat the term used in the page title, for
-example, if the page title is "Models", avoid using a section title like "Add
-new models". This might be unavoidable in some cases, but try to avoid it.
-
-Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
-
-For JS or TS code snippets remove trailing semicolons and any trailing commas
-that might not be needed.
-
-If you are making a commit prefix the commit message with `docs:`
diff --git a/AGENTS.md b/AGENTS.md
index d9ce8d392f0..761d19908d3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,10 @@
- To test opencode in the `packages/opencode` directory you can run `bun dev`
+## SDK
+
+To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
+
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
diff --git a/README.md b/README.md
index f4cb51181fe..7d1af2fbece 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,8 @@ The following PRs have been merged into this fork and are awaiting merge into up
| PR | Title | Author | Status | Description |
| ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------ |
+| [#6476](https://github.com/sst/opencode/pull/6476) | Edit suggested changes before applying | [@dmmulroy](https://github.com/dmmulroy) | Open | Press 'e' to edit AI suggestions in your editor before accepting |
+| [#6507](https://github.com/sst/opencode/pull/6507) | Optimize Ripgrep.tree() (109x faster) | [@Karavil](https://github.com/Karavil) | Open | 109x performance improvement for large repos by streaming ripgrep output |
| [#6360](https://github.com/sst/opencode/pull/6360) | Desktop: Edit Project | [@dbpolito](https://github.com/dbpolito) | Merged | Edit project name, icon color, and custom icon image in desktop sidebar |
| [#6368](https://github.com/sst/opencode/pull/6368) | Desktop: Sidebar subsessions support | [@dbpolito](https://github.com/dbpolito) | Open | Expand/collapse subsessions in sidebar with chevron indicators |
| [#6372](https://github.com/sst/opencode/pull/6372) | Desktop: Image Preview and Dedupe | [@dbpolito](https://github.com/dbpolito) | Merged | Click user attachments to preview images, dedupe file uploads |
@@ -78,7 +80,7 @@ The following PRs have been merged into this fork and are awaiting merge into up
| [#140](https://github.com/Latitudes-Dev/shuvcode/pull/140) | Toggle transparent background | [@JosXa](https://github.com/JosXa) | Open | Command palette toggle for transparent TUI background on any theme |
| [Branch](https://github.com/ariane-emory/opencode/tree/feat/glob-permissions) | Granular File Permissions | [@ariane-emory](https://github.com/ariane-emory) | N/A | Glob pattern support for `permission.edit` to restrict agent file access |
-_Last updated: 2025-12-29_
+_Last updated: 2025-12-31_
---
diff --git a/STATS.md b/STATS.md
index a5174e72e3b..db2e14f7a74 100644
--- a/STATS.md
+++ b/STATS.md
@@ -186,3 +186,4 @@
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
+| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
diff --git a/bun.lock b/bun.lock
index 6c566d0f754..231751bddb2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -71,7 +71,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -99,7 +99,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +126,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +150,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +174,7 @@
},
"packages/desktop": {
"name": "@shuvcode/desktop",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -202,7 +202,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -231,7 +231,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -247,7 +247,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.0.218",
+ "version": "1.0.220",
"bin": {
"opencode": "./bin/opencode",
},
@@ -272,6 +272,7 @@
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
+ "@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -349,7 +350,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -369,7 +370,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.0.218",
+ "version": "1.0.220",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -380,7 +381,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -393,7 +394,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -431,7 +432,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"zod": "catalog:",
},
@@ -442,7 +443,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -574,6 +575,8 @@
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
+ "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
+
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -4188,6 +4191,12 @@
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
+ "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
+
+ "@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
+
+ "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
+
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@apideck/better-ajv-errors/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index 2f0a3df12bf..29e7e527240 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,3 @@
{
- "nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY="
+ "nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
}
diff --git a/packages/app/package.json b/packages/app/package.json
index ff1033c4a66..d736bacc06e 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.0.218",
+ "version": "1.0.220",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/src/components/dialog-create-project.tsx b/packages/app/src/components/dialog-create-project.tsx
index d406b617fb8..66c5500d2d0 100644
--- a/packages/app/src/components/dialog-create-project.tsx
+++ b/packages/app/src/components/dialog-create-project.tsx
@@ -363,27 +363,30 @@ export const DialogCreateProject: Component = () => {
setActiveTab("existing")}
>
-
- Add Existing
+
+ Add Existing
+ Existing
setActiveTab("create")}
>
-
- Create New
+
+ Create New
+ New
setActiveTab("clone")}
>
-
- Git Clone
+
+ Git Clone
+ Clone
diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx
index 9a620c90342..55fd5ff5ca3 100644
--- a/packages/app/src/components/header.tsx
+++ b/packages/app/src/components/header.tsx
@@ -59,8 +59,8 @@ export function Header(props: {
when={layout.projects.list().length > 0 && params.dir}
fallback={
-
+
}
>
@@ -121,6 +121,12 @@ export function Header(props: {
+ {/* Theme and Font first - desktop only */}
+
+
+
+
+ {/* Review toggle - requires session */}
+ {/* Terminal toggle - always visible on desktop */}
+ {/* Share - requires session and share enabled */}
-
-
-
-
>
)
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 35f2b833d0e..a276731745c 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1140,6 +1140,7 @@ export const PromptInput: Component = (props) => {
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
+ const variant = local.model.variant.current()
if (isShellMode) {
sdk.client.session
@@ -1167,6 +1168,7 @@ export const PromptInput: Component = (props) => {
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
+ variant,
})
.catch((e) => {
console.error("Failed to send command", e)
@@ -1203,6 +1205,7 @@ export const PromptInput: Component = (props) => {
model,
messageID,
parts: requestParts,
+ variant,
})
.catch((e) => {
console.error("Failed to send prompt", e)
@@ -1395,9 +1398,14 @@ export const PromptInput: Component = (props) => {
- Choose model
- {command.keybind("model.choose")}
+
+
+ Choose model
+ {command.keybind("model.choose")}
+
+
+ {local.model.current()?.provider.name}
+
}
>
@@ -1411,12 +1419,25 @@ export const PromptInput: Component = (props) => {
}
>
{local.model.current()?.name ?? "Select model"}
-
- {local.model.current()?.provider.name}
-
+ 0}>
+
+ local.model.variant.cycle()}
+ classList={{
+ "text-icon-warning": !!local.model.variant.current(),
+ }}
+ >
+
+
+ {local.model.variant.current()}
+
+
+
+
diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx
index 4195f8a6ac8..72cbd887b72 100644
--- a/packages/app/src/components/status-bar.tsx
+++ b/packages/app/src/components/status-bar.tsx
@@ -1,4 +1,4 @@
-import { createMemo, Show, type ParentProps } from "solid-js"
+import { createMemo, createSignal, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
@@ -6,6 +6,8 @@ import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Icon } from "@opencode-ai/ui/icon"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
@@ -13,15 +15,36 @@ export function StatusBar(props: ParentProps) {
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
+ const [copied, setCopied] = createSignal(false)
- const directoryDisplay = createMemo(() => {
+ const directoryShort = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
- const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
+ return home && directory.startsWith(home) ? directory.replace(home, "~") : directory
+ })
+
+ const directoryDisplay = createMemo(() => {
+ const short = directoryShort()
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
+ const fullPath = createMemo(() => {
+ return sync.data.path.directory || ""
+ })
+
+ const copyPath = async () => {
+ const path = fullPath()
+ if (!path) return
+ try {
+ await navigator.clipboard.writeText(path)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (e) {
+ console.error("Failed to copy path:", e)
+ }
+ }
+
return (
@@ -49,13 +72,26 @@ export function StatusBar(props: ParentProps) {
v{platform.version}
-
- {directoryDisplay()}
-
+
+
+ {directoryDisplay()}
+
+
+
+
+
+
{props.children}
diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx
new file mode 100644
index 00000000000..cc2172edd2d
--- /dev/null
+++ b/packages/app/src/components/welcome-screen.tsx
@@ -0,0 +1,240 @@
+import { createEffect, createMemo, createSignal, onCleanup, Show, For } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { AsciiLogo } from "@opencode-ai/ui/logo"
+import { Button } from "@opencode-ai/ui/button"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Icon } from "@opencode-ai/ui/icon"
+import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { isHostedEnvironment, hasUrlQueryParam, getUrlQueryParam } from "@/utils/hosted"
+
+type ServerStatus = { healthy: boolean; version?: string }
+
+async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise
{
+ const sdk = createOpencodeClient({
+ baseUrl: url,
+ fetch,
+ signal: AbortSignal.timeout(3000),
+ })
+ return sdk.global
+ .health()
+ .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
+ .catch(() => ({ healthy: false }))
+}
+
+export interface WelcomeScreenProps {
+ attemptedUrl?: string
+ onRetry?: () => void
+}
+
+export function WelcomeScreen(props: WelcomeScreenProps) {
+ const server = useServer()
+ const platform = usePlatform()
+ const [store, setStore] = createStore({
+ url: "",
+ connecting: false,
+ error: "",
+ status: {} as Record,
+ })
+
+ const urlOverride = getUrlQueryParam()
+ const isLocalhost = () => {
+ const url = props.attemptedUrl || ""
+ return url.includes("localhost") || url.includes("127.0.0.1")
+ }
+
+ const items = createMemo(() => {
+ const list = server.list
+ return list.filter((x) => x !== props.attemptedUrl)
+ })
+
+ async function refreshHealth() {
+ const results: Record = {}
+ await Promise.all(
+ items().map(async (url) => {
+ results[url] = await checkHealth(url, platform.fetch)
+ }),
+ )
+ setStore("status", reconcile(results))
+ }
+
+ createEffect(() => {
+ if (items().length === 0) return
+ refreshHealth()
+ const interval = setInterval(refreshHealth, 10_000)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ async function handleConnect(url: string, persist = false) {
+ const normalized = normalizeServerUrl(url)
+ if (!normalized) return
+
+ setStore("connecting", true)
+ setStore("error", "")
+
+ const result = await checkHealth(normalized, platform.fetch)
+ setStore("connecting", false)
+
+ if (!result.healthy) {
+ setStore("error", "Could not connect to server")
+ return
+ }
+
+ if (persist) {
+ server.add(normalized)
+ } else {
+ server.setActive(normalized)
+ }
+ props.onRetry?.()
+ }
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ const value = normalizeServerUrl(store.url)
+ if (!value) return
+ await handleConnect(value, true)
+ }
+
+ return (
+
+
+
+
+
+
Welcome to Shuvcode
+
+ {urlOverride
+ ? `Could not connect to the server at ${urlOverride}`
+ : "Connect to a Shuvcode server to get started"}
+
+
+
+ {/* Local Server Section */}
+
+
+
+
Local Server
+
+
+
+
+
Start a local server by running:
+
shuvcode
+
or
+
npx shuvcode
+
+
+
+
handleConnect(props.attemptedUrl || "http://localhost:4096")}
+ disabled={store.connecting}
+ >
+ {store.connecting ? "Connecting..." : "Retry Connection"}
+
+
+
+ {/* Remote Server Section */}
+
+
+
+
Remote Server
+
+
+
+
+
+ Note: Connecting to a remote server means trusting that server with your data.
+
+
+
+ {/* Saved Servers Section */}
+
0}>
+
+
Saved Servers
+
+
+ {(url) => (
+ handleConnect(url)}
+ disabled={store.status[url]?.healthy === false}
+ >
+
+
+ {serverDisplayName(url)}
+
+
+ {store.status[url]?.version}
+
+
+ )}
+
+
+
+
+
+ {/* Troubleshooting Section */}
+
+
+ Troubleshooting
+
+
+ Server not running: Make sure you have a Shuvcode server running locally or accessible
+ remotely.
+
+
+ CORS blocked: The server must allow requests from{" "}
+ {location.origin}. Local servers automatically allow
+ this domain.
+
+
+ Mixed content: If connecting to an http:// server from this{" "}
+ https:// page, your browser may block the connection. Use https:// for remote
+ servers.
+
+
+
+
+
+
+ Version: {platform.version}
+
+
+
+ )
+}
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index cb7bf9cf737..59704abda2b 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -25,9 +25,12 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
+import { WelcomeScreen } from "../components/welcome-screen"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
+import { isHostedEnvironment } from "@/utils/hosted"
+import { useServer } from "./server"
type State = {
ready: boolean
@@ -66,17 +69,24 @@ type State = {
}
}
+type ConnectionState = "connecting" | "ready" | "needs_config" | "error"
+
function createGlobalSync() {
const globalSDK = useGlobalSDK()
+ const server = useServer()
const [globalStore, setGlobalStore] = createStore<{
+ connectionState: ConnectionState
ready: boolean
error?: InitError
+ attemptedUrl?: string
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
}>({
+ connectionState: "connecting",
ready: false,
+ attemptedUrl: undefined,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
@@ -402,16 +412,46 @@ function createGlobalSync() {
}
})
+ /**
+ * Probes the server health with a short timeout (2 seconds).
+ * Used for initial connection to provide quick feedback.
+ */
+ async function probeHealth(
+ url: string,
+ healthFn: () => Promise<{ data?: { healthy?: boolean } }>,
+ ): Promise<{ healthy: boolean }> {
+ try {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 2000)
+
+ const result = await healthFn()
+ clearTimeout(timeoutId)
+
+ return { healthy: result.data?.healthy === true }
+ } catch {
+ return { healthy: false }
+ }
+ }
+
async function bootstrap() {
- const health = await globalSDK.client.global
- .health()
- .then((x) => x.data)
- .catch(() => undefined)
- if (!health?.healthy) {
+ setGlobalStore("connectionState", "connecting")
+ setGlobalStore("attemptedUrl", globalSDK.url)
+
+ // Use a short timeout for the health probe (2 seconds)
+ const probeResult = await probeHealth(globalSDK.url, () => globalSDK.client.global.health())
+
+ if (!probeResult.healthy) {
+ // For hosted environments, show the welcome/configuration screen
+ if (isHostedEnvironment()) {
+ setGlobalStore("connectionState", "needs_config")
+ return
+ }
+ // For non-hosted environments, show the error page
setGlobalStore(
"error",
new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
)
+ setGlobalStore("connectionState", "error")
return
}
@@ -452,8 +492,14 @@ function createGlobalSync() {
}),
),
])
- .then(() => setGlobalStore("ready", true))
- .catch((e) => setGlobalStore("error", e))
+ .then(() => {
+ setGlobalStore("ready", true)
+ setGlobalStore("connectionState", "ready")
+ })
+ .catch((e) => {
+ setGlobalStore("error", e)
+ setGlobalStore("connectionState", "error")
+ })
}
onMount(() => {
@@ -468,6 +514,12 @@ function createGlobalSync() {
get error() {
return globalStore.error
},
+ get connectionState() {
+ return globalStore.connectionState
+ },
+ get attemptedUrl() {
+ return globalStore.attemptedUrl
+ },
child,
bootstrap,
project: {
@@ -482,10 +534,18 @@ export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
-
+
+
+
Connecting to server...
+
+
+
+ value.bootstrap()} />
+
+
-
+
{props.children}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index f6a5adeb42a..26192d9359e 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, onMount } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -10,6 +10,12 @@ import { applyTheme, DEFAULT_THEME_ID } from "@/theme/apply-theme"
import { applyFontWithLoad } from "@/fonts/apply-font"
import { getFontById, FONTS } from "@/fonts/font-definitions"
+export const REVIEW_PANE = {
+ DEFAULT_WIDTH: 450,
+ MIN_WIDTH: 200,
+ MAX_WIDTH_RATIO: 0.5,
+} as const
+
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -36,6 +42,7 @@ type Dialog = "provider" | "model" | "connect"
export type LocalProject = Partial & { worktree: string; expanded: boolean }
export type ExpandedSessions = Record
+export type ReviewDiffStyle = "unified" | "split"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
@@ -57,7 +64,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
opened: false,
state: "pane" as "pane" | "tab",
- width: 450,
+ width: REVIEW_PANE.DEFAULT_WIDTH as number,
+ diffStyle: "split" as ReviewDiffStyle,
},
session: {
width: 600,
@@ -128,11 +136,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const list = createMemo(() => enriched().flatMap(colorize))
onMount(() => {
+ // Load project sessions
Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
+
+ // Normalize persisted review state (ensure opened defaults to false for old/missing state)
+ if (store.review === undefined || store.review.opened === undefined) {
+ setStore("review", "opened", false)
+ }
})
createEffect(() => {
@@ -204,6 +218,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: createMemo(() => store.review?.opened ?? true),
state: createMemo(() => store.review?.state ?? "pane"),
width: createMemo(() => store.review?.width ?? 450),
+ diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
+ setDiffStyle(diffStyle: ReviewDiffStyle) {
+ if (!store.review) {
+ setStore("review", { opened: true, diffStyle })
+ return
+ }
+ setStore("review", "diffStyle", diffStyle)
+ },
open() {
setStore("review", "opened", true)
},
@@ -226,6 +248,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
+ // ResizeHandle already enforces min/max constraints
if (!store.session) {
setStore("session", { width })
} else {
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index cefcc9fec4d..793494af3c0 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -115,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
+ variant?: Record
}>({
user: [],
recent: [],
+ variant: {},
}),
)
@@ -272,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
},
+ variant: {
+ current() {
+ const m = current()
+ if (!m) return undefined
+ const key = `${m.provider.id}/${m.id}`
+ return store.variant?.[key]
+ },
+ list() {
+ const m = current()
+ if (!m) return []
+ if (!m.variants) return []
+ return Object.keys(m.variants)
+ },
+ set(value: string | undefined) {
+ const m = current()
+ if (!m) return
+ const key = `${m.provider.id}/${m.id}`
+ if (!store.variant) {
+ setStore("variant", { [key]: value })
+ } else {
+ setStore("variant", key, value)
+ }
+ },
+ cycle() {
+ const variants = this.list()
+ if (variants.length === 0) return
+ const currentVariant = this.current()
+ if (!currentVariant) {
+ this.set(variants[0])
+ return
+ }
+ const index = variants.indexOf(currentVariant)
+ if (index === -1 || index === variants.length - 1) {
+ this.set(undefined)
+ return
+ }
+ this.set(variants[index + 1])
+ },
+ },
}
})()
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index c77b027ec7d..2b3a8d4349c 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -39,10 +39,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
- "server.v3",
+ "server.v4",
createStore({
list: [] as string[],
projects: {} as Record,
+ active: "" as string, // Persist the last active server
}),
)
@@ -51,7 +52,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
- setActiveRaw(url)
+ batch(() => {
+ setActiveRaw(url)
+ setStore("active", url) // Persist active server
+ })
}
function add(input: string) {
@@ -60,7 +64,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) {
- setActiveRaw(url)
+ batch(() => {
+ setActiveRaw(url)
+ setStore("active", url)
+ })
return
}
@@ -69,6 +76,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setStore("list", store.list.length, url)
}
setActiveRaw(url)
+ setStore("active", url)
})
}
@@ -82,13 +90,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
batch(() => {
setStore("list", list)
setActiveRaw(next)
+ setStore("active", next)
})
}
+ // Initialize active server from persisted state or default
createEffect(() => {
if (!ready()) return
if (active()) return
- const url = normalizeServerUrl(props.defaultUrl)
+ // Priority: persisted active > default URL
+ const persistedActive = store.active ? normalizeServerUrl(store.active) : undefined
+ const url = persistedActive || normalizeServerUrl(props.defaultUrl)
if (!url) return
setActiveRaw(url)
})
@@ -120,10 +132,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const origin = createMemo(() => projectsKey(active()))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
+ const isLocal = createMemo(() => origin() === "local")
return {
ready: isReady,
healthy,
+ isLocal,
get url() {
return active()
},
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 700f8a14048..b225bdd6be9 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -88,7 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
retry(() => sdk.client.session.get({ sessionID })),
- retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
+ retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx
index 70a5a9f23a8..ba1b9cd2654 100644
--- a/packages/app/src/pages/error.tsx
+++ b/packages/app/src/pages/error.tsx
@@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { AsciiLogo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
+import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
@@ -181,6 +182,25 @@ interface ErrorPageProps {
export const ErrorPage: Component = (props) => {
const platform = usePlatform()
+ const [store, setStore] = createStore({
+ checking: false,
+ version: undefined as string | undefined,
+ })
+
+ async function checkForUpdates() {
+ if (!platform.checkUpdate) return
+ setStore("checking", true)
+ const result = await platform.checkUpdate()
+ setStore("checking", false)
+ if (result.updateAvailable && result.version) setStore("version", result.version)
+ }
+
+ async function installUpdate() {
+ if (!platform.update || !platform.restart) return
+ await platform.update()
+ await platform.restart()
+ }
+
return (
@@ -198,9 +218,25 @@ export const ErrorPage: Component
= (props) => {
label="Error Details"
hideLabel
/>
-
- Restart
-
+
+
+ Restart
+
+
+
+ {store.checking ? "Checking..." : "Check for updates"}
+
+ }
+ >
+
+ Update to {store.version}
+
+
+
+
Please report this error to the shuvcode team
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 67e6b132252..86d00d1b498 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -17,6 +17,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
+import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
@@ -37,6 +38,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { PullToRefresh } from "@/components/pull-to-refresh"
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
@@ -49,6 +51,7 @@ import { applyTheme } from "@/theme/apply-theme"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { useServer } from "@/context/server"
import { Header } from "@/components/header"
export default function Layout(props: ParentProps) {
@@ -84,6 +87,7 @@ export default function Layout(props: ParentProps) {
const globalSync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
+ const server = useServer()
const notification = useNotification()
const navigate = useNavigate()
const providers = useProviders()
@@ -473,7 +477,6 @@ export default function Layout(props: ParentProps) {
else navigate("/")
}
-
createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
@@ -1038,9 +1041,11 @@ export default function Layout(props: ParentProps) {
Share feedback
-
+
+
+
v{__APP_VERSION__} ({__COMMIT_HASH__})
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c45830bea07..52b2fa70807 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -42,7 +42,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
-import { useLayout } from "@/context/layout"
+import { useLayout, REVIEW_PANE } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
@@ -205,7 +205,6 @@ export default function Page() {
stepsExpanded: true,
mobileTabsOpen: false,
mobileTerminalFullscreen: false,
- diffSplit: false,
})
let inputRef!: HTMLDivElement
@@ -773,6 +772,7 @@ export default function Page() {
)
}
+ const hasReviewContent = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
const showTabs = createMemo(() => layout.review.opened())
const tabsValue = createMemo(() => tabs().active() ?? "review")
@@ -879,7 +879,7 @@ export default function Page() {
direction="horizontal"
size={layout.session.width()}
min={320}
- max={window.innerWidth * 0.7}
+ max={window.innerWidth - REVIEW_PANE.MIN_WIDTH}
onResize={layout.session.resize}
/>
@@ -941,83 +941,88 @@ export default function Page() {
-
-
-
-
setStore("diffSplit", (x) => !x)}
- >
- {store.diffSplit ? "Inline" : "Split"}
-
- }
- />
+
+
+
+
No files to review
+
Changes will appear here
+
-
-
-
- {(tab) => {
- const [file] = createResource(
- () => tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
-
-
- {(content) => {
- const f = file()!
- const isPreviewableImage =
- content.encoding === "base64" &&
- content.mimeType?.startsWith("image/") &&
- content.mimeType !== "image/svg+xml"
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- )
+ }
+ >
+
+
+
+
-
- )
- }}
-
+ diffs={diffs()}
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
+ />
+
+
+
+
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+
+ {(content) => {
+ const f = file()!
+ const isPreviewableImage =
+ content.encoding === "base64" &&
+ content.mimeType?.startsWith("image/") &&
+ content.mimeType !== "image/svg+xml"
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+ )
+ }}
+
+
@@ -1220,16 +1225,8 @@ export default function Page() {
container: "px-4",
}}
diffs={diffs()}
- split={store.diffSplit}
- actions={
- setStore("diffSplit", (x) => !x)}
- >
- {store.diffSplit ? "Inline" : "Split"}
-
- }
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
/>
diff --git a/packages/app/src/utils/hosted.ts b/packages/app/src/utils/hosted.ts
new file mode 100644
index 00000000000..94c4c9b9c91
--- /dev/null
+++ b/packages/app/src/utils/hosted.ts
@@ -0,0 +1,25 @@
+/**
+ * Checks if the app is running in a hosted environment (app.shuv.ai or app.opencode.ai).
+ * In hosted environments, users need to configure their server connection.
+ */
+export function isHostedEnvironment(): boolean {
+ if (typeof window === "undefined") return false
+ return location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai")
+}
+
+/**
+ * Checks if a ?url= query parameter was provided in the URL.
+ * This indicates the user is trying to connect to a specific server.
+ */
+export function hasUrlQueryParam(): boolean {
+ if (typeof window === "undefined") return false
+ return new URLSearchParams(document.location.search).has("url")
+}
+
+/**
+ * Gets the ?url= query parameter value if present.
+ */
+export function getUrlQueryParam(): string | null {
+ if (typeof window === "undefined") return null
+ return new URLSearchParams(document.location.search).get("url")
+}
diff --git a/packages/app/test/hosted.test.ts b/packages/app/test/hosted.test.ts
new file mode 100644
index 00000000000..09d1620416e
--- /dev/null
+++ b/packages/app/test/hosted.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+
+// Note: These tests require the happy-dom environment set up via bunfig.toml
+
+describe("hosted.ts utilities", () => {
+ let originalHostname: string
+ let originalSearch: string
+
+ beforeEach(() => {
+ originalHostname = window.location.hostname
+ originalSearch = window.location.search
+ })
+
+ afterEach(() => {
+ // Reset location properties (happy-dom allows this)
+ Object.defineProperty(window.location, "hostname", {
+ value: originalHostname,
+ writable: true,
+ })
+ Object.defineProperty(window.location, "search", {
+ value: originalSearch,
+ writable: true,
+ })
+ })
+
+ describe("isHostedEnvironment", () => {
+ test("returns true for opencode.ai domains", async () => {
+ Object.defineProperty(window.location, "hostname", {
+ value: "app.opencode.ai",
+ writable: true,
+ })
+
+ // Dynamic import to get fresh evaluation
+ const { isHostedEnvironment } = await import("../src/utils/hosted")
+ expect(isHostedEnvironment()).toBe(true)
+ })
+
+ test("returns true for shuv.ai domains", async () => {
+ Object.defineProperty(window.location, "hostname", {
+ value: "app.shuv.ai",
+ writable: true,
+ })
+
+ const { isHostedEnvironment } = await import("../src/utils/hosted")
+ expect(isHostedEnvironment()).toBe(true)
+ })
+
+ test("returns false for localhost", async () => {
+ Object.defineProperty(window.location, "hostname", {
+ value: "localhost",
+ writable: true,
+ })
+
+ const { isHostedEnvironment } = await import("../src/utils/hosted")
+ expect(isHostedEnvironment()).toBe(false)
+ })
+
+ test("returns false for other domains", async () => {
+ Object.defineProperty(window.location, "hostname", {
+ value: "example.com",
+ writable: true,
+ })
+
+ const { isHostedEnvironment } = await import("../src/utils/hosted")
+ expect(isHostedEnvironment()).toBe(false)
+ })
+ })
+
+ describe("hasUrlQueryParam", () => {
+ test("returns true when ?url= parameter exists", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "?url=http://localhost:4096",
+ writable: true,
+ })
+
+ const { hasUrlQueryParam } = await import("../src/utils/hosted")
+ expect(hasUrlQueryParam()).toBe(true)
+ })
+
+ test("returns false when no ?url= parameter", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "",
+ writable: true,
+ })
+
+ const { hasUrlQueryParam } = await import("../src/utils/hosted")
+ expect(hasUrlQueryParam()).toBe(false)
+ })
+
+ test("returns false when other parameters exist but not ?url=", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "?foo=bar&baz=qux",
+ writable: true,
+ })
+
+ const { hasUrlQueryParam } = await import("../src/utils/hosted")
+ expect(hasUrlQueryParam()).toBe(false)
+ })
+ })
+
+ describe("getUrlQueryParam", () => {
+ test("returns the URL value when present", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "?url=http://localhost:4096",
+ writable: true,
+ })
+
+ const { getUrlQueryParam } = await import("../src/utils/hosted")
+ expect(getUrlQueryParam()).toBe("http://localhost:4096")
+ })
+
+ test("returns null when not present", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "",
+ writable: true,
+ })
+
+ const { getUrlQueryParam } = await import("../src/utils/hosted")
+ expect(getUrlQueryParam()).toBeNull()
+ })
+
+ test("handles URL-encoded values", async () => {
+ Object.defineProperty(window.location, "search", {
+ value: "?url=https%3A%2F%2Fmy-server.example.com%3A8080",
+ writable: true,
+ })
+
+ const { getUrlQueryParam } = await import("../src/utils/hosted")
+ expect(getUrlQueryParam()).toBe("https://my-server.example.com:8080")
+ })
+ })
+})
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 2b4bb72e6aa..36c00e3ff74 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
index 99b5939669a..b099e900e6b 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
@@ -1,5 +1,5 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, Show } from "solid-js"
+import { createEffect, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
@@ -68,6 +68,12 @@ export function ReloadSection() {
reloadTrigger: "",
})
+ const processingFee = createMemo(() => {
+ const reloadAmount = billingInfo()?.reloadAmount
+ if (!reloadAmount) return "0.00"
+ return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
+ })
+
createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false)
@@ -104,8 +110,8 @@ export function ReloadSection() {
}
>
- Auto reload is enabled . We'll reload ${billingInfo()?.reloadAmount} (+$1.23 processing fee)
- when balance reaches ${billingInfo()?.reloadTrigger} .
+ Auto reload is enabled . We'll reload ${billingInfo()?.reloadAmount} (+${processingFee()}{" "}
+ processing fee) when balance reaches ${billingInfo()?.reloadTrigger} .
show()}>
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index ab9875388dc..adc77db2b15 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.0.218",
+ "version": "1.0.220",
"private": true,
"type": "module",
"dependencies": {
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index a1a1dc737bb..05b5db42359 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.0.218",
+ "version": "1.0.220",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index f045fe0e8e3..5fdabe1b703 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.0.218",
+ "version": "1.0.220",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index af6a03f2fc4..d99ec22bbc5 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@shuvcode/desktop",
"private": true,
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index a36da412520..6c210693517 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -107,7 +107,10 @@ const platform: Platform = {
await Promise.resolve()
.then(() => {
- const notification = new Notification(title, { body: description ?? "" })
+ const notification = new Notification(title, {
+ body: description ?? "",
+ icon: "https://opencode.ai/favicon-96x96.png",
+ })
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index ec36524fa07..5d607833df4 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.0.218",
+ "version": "1.0.220",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index 8f3c503dc31..df3ecb9d35c 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/packages/enterprise/src/routes/share/[shareID].tsx
@@ -32,7 +32,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
- {props.children}
+ {props.children}
),
})),
)
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index e3071a8b6fc..38e66feb214 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.0.218"
+version = "1.0.220"
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.218/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.220/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.218/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.220/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.220/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.218/opencode-linux-x64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.220/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.218/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.220/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index b45e3a973a2..76f4b054e5e 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.0.218",
+ "version": "1.0.220",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index a5bf5a1e34b..2ea14b1d327 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.0.218",
+ "version": "1.0.220",
"name": "opencode",
"type": "module",
"private": true,
@@ -66,6 +66,7 @@
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
+ "@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
new file mode 100644
index 00000000000..5a51a044df3
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -0,0 +1,51 @@
+import { EOL } from "os"
+import { basename } from "path"
+import { Agent } from "../../../agent/agent"
+import { Provider } from "../../../provider/provider"
+import { ToolRegistry } from "../../../tool/registry"
+import { Wildcard } from "../../../util/wildcard"
+import { bootstrap } from "../../bootstrap"
+import { cmd } from "../cmd"
+
+export const AgentCommand = cmd({
+ command: "agent ",
+ builder: (yargs) =>
+ yargs.positional("name", {
+ type: "string",
+ demandOption: true,
+ description: "Agent name",
+ }),
+ async handler(args) {
+ await bootstrap(process.cwd(), async () => {
+ const agentName = args.name as string
+ const agent = await Agent.get(agentName)
+ if (!agent) {
+ process.stderr.write(
+ `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
+ )
+ process.exit(1)
+ }
+ const resolvedTools = await resolveTools(agent)
+ const output = {
+ ...agent,
+ tools: resolvedTools,
+ toolOverrides: agent.tools,
+ }
+ process.stdout.write(JSON.stringify(output, null, 2) + EOL)
+ })
+ },
+})
+
+async function resolveTools(agent: Agent.Info) {
+ const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
+ const toolOverrides = {
+ ...agent.tools,
+ ...(await ToolRegistry.enabled(agent)),
+ }
+ const availableTools = await ToolRegistry.tools(providerID, agent)
+ const resolved: Record = {}
+ for (const tool of availableTools) {
+ resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
+ }
+ return resolved
+}
diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts
index 3b0aefa2849..789c726ccec 100644
--- a/packages/opencode/src/cli/cmd/debug/index.ts
+++ b/packages/opencode/src/cli/cmd/debug/index.ts
@@ -8,6 +8,7 @@ import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot"
+import { AgentCommand } from "./agent"
export const DebugCommand = cmd({
command: "debug",
@@ -20,6 +21,7 @@ export const DebugCommand = cmd({
.command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand)
+ .command(AgentCommand)
.command(PathsCommand)
.command({
command: "wait",
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 94f1b549f40..81e250d208f 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -20,6 +20,17 @@ interface SessionStats {
}
}
toolUsage: Record
+ modelUsage: Record<
+ string,
+ {
+ messages: number
+ tokens: {
+ input: number
+ output: number
+ }
+ cost: number
+ }
+ >
dateRange: {
earliest: number
latest: number
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
describe: "number of tools to show (default: all)",
type: "number",
})
+ .option("models", {
+ describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
+ })
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
- displayStats(stats, args.tools)
+
+ let modelLimit: number | undefined
+ if (args.models === true) {
+ modelLimit = Infinity
+ } else if (typeof args.models === "number") {
+ modelLimit = args.models
+ }
+
+ displayStats(stats, args.tools, modelLimit)
})
},
})
@@ -121,6 +143,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
},
},
toolUsage: {},
+ modelUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
@@ -154,17 +177,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record = {}
+ let sessionModelUsage: Record<
+ string,
+ {
+ messages: number
+ tokens: {
+ input: number
+ output: number
+ }
+ cost: number
+ }
+ > = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
+ const modelKey = `${message.info.providerID}/${message.info.modelID}`
+ if (!sessionModelUsage[modelKey]) {
+ sessionModelUsage[modelKey] = {
+ messages: 0,
+ tokens: { input: 0, output: 0 },
+ cost: 0,
+ }
+ }
+ sessionModelUsage[modelKey].messages++
+ sessionModelUsage[modelKey].cost += message.info.cost || 0
+
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
+
+ sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
+ sessionModelUsage[modelKey].tokens.output +=
+ (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
}
}
@@ -181,6 +230,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionTokens,
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
sessionToolUsage,
+ sessionModelUsage,
earliestTime: session.time.created,
latestTime: session.time.updated,
}
@@ -204,6 +254,20 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
+
+ for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
+ if (!stats.modelUsage[model]) {
+ stats.modelUsage[model] = {
+ messages: 0,
+ tokens: { input: 0, output: 0 },
+ cost: 0,
+ }
+ }
+ stats.modelUsage[model].messages += usage.messages
+ stats.modelUsage[model].tokens.input += usage.tokens.input
+ stats.modelUsage[model].tokens.output += usage.tokens.output
+ stats.modelUsage[model].cost += usage.cost
+ }
}
}
@@ -228,7 +292,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}
-export function displayStats(stats: SessionStats, toolLimit?: number) {
+export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -267,6 +331,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
console.log("└────────────────────────────────────────────────────────┘")
console.log()
+ // Model Usage section
+ if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
+ const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
+ const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
+
+ console.log("┌────────────────────────────────────────────────────────┐")
+ console.log("│ MODEL USAGE │")
+ console.log("├────────────────────────────────────────────────────────┤")
+
+ for (const [model, usage] of modelsToDisplay) {
+ console.log(`│ ${model.padEnd(54)} │`)
+ console.log(renderRow(" Messages", usage.messages.toLocaleString()))
+ console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
+ console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
+ console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
+ console.log("├────────────────────────────────────────────────────────┤")
+ }
+ // Remove last separator and add bottom border
+ process.stdout.write("\x1B[1A") // Move up one line
+ console.log("└────────────────────────────────────────────────────────┘")
+ }
+ console.log()
+
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index db030efe8bc..c346e05aed9 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -264,7 +264,7 @@ export function Autocomplete(props: {
if (command.sessionOnly && !s) continue
results.push({
- display: "/" + command.name,
+ display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
aliases: command.aliases?.map((a) => "/" + a),
onSelect: () => {
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index aaa5c0b9cdb..b2ab403b67e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -18,6 +18,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
+import { parseUriList } from "../../util/uri"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { Ide } from "@/ide"
@@ -293,9 +294,10 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = trigger === "prompt" ? "" : text
- const content = await Editor.open({ value, renderer })
- if (!content) return
+ const result = await Editor.open({ value, renderer })
+ if (!result.ok) return
+ const content = result.content
input.setText(content)
// Update positions for nonTextParts based on their location in new content
@@ -1114,6 +1116,39 @@ export function Prompt(props: PromptProps) {
return
}
+ // Handle file:// URIs or text/uri-list (common for drag-and-drop on Linux)
+ if (pastedContent.includes("file://")) {
+ const paths = parseUriList(pastedContent)
+ if (paths.length > 0) {
+ let handled = false
+ for (const path of paths) {
+ try {
+ const file = Bun.file(path)
+ if (file.type.startsWith("image/")) {
+ const content = await file
+ .arrayBuffer()
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
+ .catch(() => {})
+ if (content) {
+ await pasteImage({
+ filename: file.name,
+ mime: file.type,
+ content,
+ })
+ handled = true
+ continue
+ }
+ }
+ } catch {}
+ }
+
+ if (handled) {
+ event.preventDefault()
+ return
+ }
+ }
+ }
+
// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.ts b/packages/opencode/src/cli/cmd/tui/component/tips.ts
index be329076c0a..c237784ad81 100644
--- a/packages/opencode/src/cli/cmd/tui/component/tips.ts
+++ b/packages/opencode/src/cli/cmd/tui/component/tips.ts
@@ -28,7 +28,7 @@ export const TIPS = [
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
- "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
+ "Use {highlight}@{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
index 24a9a5544e1..9fcb3a66a15 100644
--- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
@@ -1,6 +1,6 @@
import { Global } from "@/global"
import { createSignal, type Setter } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
@@ -40,8 +40,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
return kvStore[key] ?? defaultValue
},
set(key: string, value: any) {
+ if (!ready()) {
+ console.warn("KV store not ready, write deferred")
+ return
+ }
setKvStore(key, value)
- Bun.write(file, JSON.stringify(kvStore, null, 2))
+ Bun.write(file, JSON.stringify(unwrap(kvStore), null, 2))
},
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index 71701684250..03122c8c23b 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -254,7 +254,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const next = favorites[index]
if (!next) return
setModelStore("model", agent.current().name, { ...next })
- const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
+ const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
@@ -274,7 +274,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
setModelStore("model", agent.current().name, model)
if (options?.recent) {
- const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
+ const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
@@ -320,9 +320,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
- return Object.entries(info.variants)
- .filter(([_, v]) => !v.disabled)
- .map(([name]) => name)
+ return Object.keys(info.variants)
},
set(value: string | undefined) {
const m = currentModel()
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 45d24d03189..07146ef8885 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -31,6 +31,7 @@ import { SearchInput, type SearchInputRef } from "@tui/component/prompt/search"
import type {
AssistantMessage,
Part,
+ Permission,
ToolPart,
UserMessage,
TextPart,
@@ -73,6 +74,7 @@ import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
+import { PermissionEditor } from "@/permission/editor"
import { Footer } from "./footer.tsx"
import { extend } from "@opentui/solid"
import { GhosttyTerminalRenderable } from "ghostty-opentui/opentui"
@@ -432,6 +434,103 @@ export function Session() {
let searchRef: SearchInputRef
const keybind = useKeybind()
+ // Helper: Find next visible message boundary in direction
+ const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
+ const children = scroll.getChildren()
+ const messagesList = messages()
+ const scrollTop = scroll.y
+
+ // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
+ const visibleMessages = children
+ .filter((c) => {
+ if (!c.id) return false
+ const message = messagesList.find((m) => m.id === c.id)
+ if (!message) return false
+
+ // Check if message has valid non-synthetic, non-ignored text parts
+ const parts = sync.data.part[message.id]
+ if (!parts || !Array.isArray(parts)) return false
+
+ return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
+ })
+ .sort((a, b) => a.y - b.y)
+
+ if (visibleMessages.length === 0) return null
+
+ if (direction === "next") {
+ // Find first message below current position
+ return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
+ }
+ // Find last message above current position
+ return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
+ }
+
+ // Helper: Scroll to message in direction or fallback to page scroll
+ const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType) => {
+ const targetID = findNextVisibleMessage(direction)
+
+ if (!targetID) {
+ scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
+ dialog.clear()
+ return
+ }
+
+ const child = scroll.getChildren().find((c) => c.id === targetID)
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
+ dialog.clear()
+ }
+
+ const renderer = useRenderer()
+
+ async function handleEditPermission(permission: Permission) {
+ if (!PermissionEditor.canEdit(permission)) {
+ toast.show({ message: "This permission cannot be edited", variant: "error" })
+ return
+ }
+
+ const content = PermissionEditor.getContent(permission)
+ const ext = PermissionEditor.getExtension(permission)
+ const line = PermissionEditor.getStartLine(
+ permission.metadata.originalContent as string,
+ permission.metadata.suggestedContent as string,
+ )
+
+ const result = await Editor.open({ value: content, renderer, extension: ext, line })
+
+ if (!result.ok) {
+ const message =
+ result.reason === "no-editor"
+ ? "No editor configured (set EDITOR or VISUAL env var)"
+ : "Editor closed without saving"
+ toast.show({ message, variant: result.reason === "no-editor" ? "error" : "warning" })
+ return
+ }
+
+ const edited = result.content
+
+ // Check if user actually made changes
+ if (!PermissionEditor.hasChanges(content, edited)) {
+ // No changes - treat as normal accept
+ sdk.client.permission.respond({
+ permissionID: permission.id,
+ sessionID: route.sessionID,
+ response: "once",
+ })
+ return
+ }
+
+ // Build the modify response
+ const modifyData: PermissionEditor.SingleFileModifyData = {
+ content: edited,
+ }
+ sdk.client.permission.respond({
+ permissionID: permission.id,
+ sessionID: route.sessionID,
+ response: "modify",
+ modifyData,
+ })
+ }
+
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
@@ -467,14 +566,20 @@ export function Session() {
const first = permissions()[0]
if (first) {
+ const editKeybind = sync.data.config.keybinds?.permission_edit ?? "e"
const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once"
if (evt.name === "a") return "always"
if (evt.name === "d") return "reject"
if (evt.name === "escape") return "reject"
+ if (evt.name === editKeybind && PermissionEditor.isEditable(first)) return "edit"
return
})
+ if (response === "edit") {
+ handleEditPermission(first)
+ return
+ }
if (response) {
sdk.client.permission.respond({
permissionID: first.id,
@@ -1169,9 +1274,9 @@ export function Session() {
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
- if (result !== undefined) {
+ if (result.ok) {
// User edited the file, save the changes
- await Bun.write(filepath, result)
+ await Bun.write(filepath, result.content)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
@@ -1264,7 +1369,6 @@ export function Session() {
})
const dialog = useDialog()
- const renderer = useRenderer()
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
@@ -1990,6 +2094,12 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
d
deny
+ {PermissionEditor.isEditable(permission) && (
+
+ {sync.data.config.keybinds?.permission_edit ?? "e"}
+ edit
+
+ )}
)}
@@ -2339,8 +2449,8 @@ ToolRegistry.register({
- {keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
- to navigate between subagent sessions
+ {keybind.print("session_child_cycle")}
+ view subagents
)
@@ -2425,7 +2535,16 @@ ToolRegistry.register({
const ft = createMemo(() => filetype(props.input.filePath))
- const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
+ const diffContent = createMemo(() => {
+ // First check completed metadata
+ if (props.metadata.diff) return props.metadata.diff
+ // Then check pending permission metadata - compute diff from suggestedContent
+ const m = props.permission
+ if (m?.originalContent !== undefined && m?.suggestedContent !== undefined && m?.filePath) {
+ return PermissionEditor.computeDiff(m.filePath, m.originalContent, m.suggestedContent)
+ }
+ return undefined
+ })
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 8cc52ab8c2c..fbc5853b528 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,5 +1,5 @@
import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match } from "solid-js"
+import { createMemo, For, Show, Switch, Match, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { useRoute } from "../../context/route"
@@ -30,8 +30,28 @@ export function Sidebar(props: { sessionID: string; width: number }) {
subagents: true,
})
- // Sort MCP servers alphabetically for consistent display order
- const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
+ const setExpandedWithPersist = (key: keyof typeof expanded, value: boolean) => {
+ setExpanded(key, value)
+ kv.set(`sidebar_expanded_${key}`, value)
+ }
+
+ createEffect(() => {
+ if (!kv.ready) return
+ setExpanded({
+ context: kv.get("sidebar_expanded_context", true),
+ mcp: kv.get("sidebar_expanded_mcp", true),
+ diff: kv.get("sidebar_expanded_diff", true),
+ lsp: kv.get("sidebar_expanded_lsp", true),
+ subagents: kv.get("sidebar_expanded_subagents", true),
+ })
+ })
+
+ // Sort MCP servers alphabetically for consistent display order, filtering out disabled servers
+ const mcpEntries = createMemo(() =>
+ Object.entries(sync.data.mcp)
+ .filter(([_, item]) => item.status !== "disabled")
+ .sort(([a], [b]) => a.localeCompare(b))
+ )
const taskToolParts = createMemo(() => {
const parts: ToolPart[] = []
@@ -116,7 +136,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{/* Context Section */}
- setExpanded("context", !expanded.context)}>
+ setExpandedWithPersist("context", !expanded.context)}>
{expanded.context ? "▼" : "▶"}
Context
@@ -135,7 +155,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{/* Subagents Section */}
0}>
- setExpanded("subagents", !expanded.subagents)}>
+ setExpandedWithPersist("subagents", !expanded.subagents)}>
{expanded.subagents ? "▼" : "▶"}
Subagents
@@ -214,7 +234,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{/* MCP Section */}
0}>
- setExpanded("mcp", !expanded.mcp)}>
+ setExpandedWithPersist("mcp", !expanded.mcp)}>
{expanded.mcp ? "▼" : "▶"}
MCP
@@ -270,7 +290,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{/* LSP Section */}
- setExpanded("lsp", !expanded.lsp)}>
+ setExpandedWithPersist("lsp", !expanded.lsp)}>
{expanded.lsp ? "▼" : "▶"}
LSP
@@ -313,7 +333,7 @@ export function Sidebar(props: { sessionID: string; width: number }) {
{/* Changed Files Section */}
0}>
- setExpanded("diff", !expanded.diff)}>
+ setExpandedWithPersist("diff", !expanded.diff)}>
{expanded.diff ? "▼" : "▶"}
Changed Files
diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts
index f98e24b0695..0c154b7724c 100644
--- a/packages/opencode/src/cli/cmd/tui/util/editor.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts
@@ -5,19 +5,37 @@ import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
export namespace Editor {
- export async function open(opts: { value: string; renderer: CliRenderer }): Promise {
+ export type Result = { ok: true; content: string } | { ok: false; reason: "no-editor" | "cancelled" }
+
+ export async function open(opts: {
+ value: string
+ renderer: CliRenderer
+ extension?: string
+ line?: number
+ }): Promise {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
- if (!editor) return
+ if (!editor) {
+ return { ok: false, reason: "no-editor" }
+ }
- const filepath = join(tmpdir(), `${Date.now()}.md`)
+ const ext = opts.extension ?? ".md"
+ const filepath = join(tmpdir(), `${Date.now()}${ext}`)
await using _ = defer(async () => rm(filepath, { force: true }))
await Bun.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
+ const cmd = [...parts]
+
+ // Common editors support +line syntax: vim, nvim, nano, code, emacs, etc.
+ if (opts.line && opts.line > 0) {
+ cmd.push(`+${opts.line}`)
+ }
+ cmd.push(filepath)
+
const proc = Bun.spawn({
- cmd: [...parts, filepath],
+ cmd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
@@ -27,6 +45,10 @@ export namespace Editor {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
- return content || undefined
+
+ if (!content) {
+ return { ok: false, reason: "cancelled" }
+ }
+ return { ok: true, content }
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/util/uri.ts b/packages/opencode/src/cli/cmd/tui/util/uri.ts
new file mode 100644
index 00000000000..b5aea5f081e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/uri.ts
@@ -0,0 +1,22 @@
+/**
+ * Parse text/uri-list format or file:// URIs into filesystem paths.
+ * Handles Linux, macOS, and Windows path formats.
+ */
+export function parseUriList(text: string): string[] {
+ return text
+ .split(/[\r\n]+/) // Handle both \n and \r\n line endings
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#")) // Skip comments per RFC 2483
+ .map((line) => line.replace(/^["']+|["']+$/g, "")) // Strip both quote types
+ .map((line) => {
+ // Skip non-file:// URIs
+ if (line.includes("://") && !line.startsWith("file://")) return ""
+ // Strip file:// prefix, including optional localhost
+ const url = line.replace(/^file:\/\/(localhost)?/, "")
+ // Handle Windows drive letters: /C:/path -> C:/path
+ if (url.match(/^\/[A-Za-z]:\//)) return url.slice(1)
+ return url
+ })
+ .map((line) => decodeURIComponent(line))
+ .filter(Boolean); // Remove empty strings
+}
diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts
index 397f2ba3e20..fe5731d0713 100644
--- a/packages/opencode/src/cli/network.ts
+++ b/packages/opencode/src/cli/network.ts
@@ -17,6 +17,12 @@ const options = {
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false,
},
+ cors: {
+ type: "string" as const,
+ array: true,
+ describe: "additional domains to allow for CORS",
+ default: [] as string[],
+ },
}
export type NetworkOptions = InferredOptionTypes
@@ -30,6 +36,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
+ const corsExplicitlySet = process.argv.includes("--cors")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
@@ -38,6 +45,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
: mdns && !config?.server?.hostname
? "0.0.0.0"
: (config?.server?.hostname ?? args.hostname)
+ const configCors = config?.server?.cors ?? []
+ const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
+ const cors = [...configCors, ...argsCors]
- return { hostname, port, mdns }
+ return { hostname, port, mdns, cors }
}
diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index a037408391d..df86b612cd9 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -7,6 +7,7 @@ import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
+import { MCP } from "../mcp"
export namespace Command {
export const Event = {
@@ -27,16 +28,32 @@ export namespace Command {
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
- template: z.string(),
+ mcp: z.boolean().optional(),
+ // workaround for zod not supporting async functions natively so we use getters
+ // https://zod.dev/v4/changelog?id=zfunction
+ template: z.promise(z.string()).or(z.string()),
type: z.enum(["template", "plugin"]).default("template"),
subtask: z.boolean().optional(),
sessionOnly: z.boolean().optional(),
aliases: z.array(z.string()).optional(),
+ hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
- export type Info = z.infer
+
+ // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
+ export type Info = Omit, "template"> & { template: Promise | string }
+
+ export function hints(template: string): string[] {
+ const result: string[] = []
+ const numbered = template.match(/\$\d+/g)
+ if (numbered) {
+ for (const match of [...new Set(numbered)].sort()) result.push(match)
+ }
+ if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
+ return result
+ }
export const Default = {
INIT: "init",
@@ -51,14 +68,20 @@ export namespace Command {
name: Default.INIT,
type: "template",
description: "create/update AGENTS.md",
- template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
+ get template() {
+ return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
+ },
+ hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: Default.REVIEW,
type: "template",
description: "review changes [commit|branch|pr], defaults to uncommitted",
- template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
+ get template() {
+ return PROMPT_REVIEW.replace("${path}", Instance.worktree)
+ },
subtask: true,
+ hints: hints(PROMPT_REVIEW),
},
}
@@ -69,8 +92,38 @@ export namespace Command {
agent: command.agent,
model: command.model,
description: command.description,
- template: command.template,
+ get template() {
+ return command.template
+ },
subtask: command.subtask,
+ hints: hints(command.template),
+ }
+ }
+ for (const [name, prompt] of Object.entries(await MCP.prompts())) {
+ result[name] = {
+ name,
+ type: "template",
+ mcp: true,
+ description: prompt.description,
+ get template() {
+ // since a getter can't be async we need to manually return a promise here
+ return new Promise(async (resolve, reject) => {
+ const template = await MCP.getPrompt(
+ prompt.client,
+ prompt.name,
+ prompt.arguments
+ ? // substitute each argument with $1, $2, etc.
+ Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
+ : {},
+ ).catch(reject)
+ resolve(
+ template?.messages
+ .map((message) => (message.content.type === "text" ? message.content.text : ""))
+ .join("\n") || "",
+ )
+ })
+ },
+ hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
@@ -88,6 +141,7 @@ export namespace Command {
template: "", // Plugin commands don't use templates
sessionOnly: cmd.sessionOnly,
aliases: cmd.aliases,
+ hints: [],
}
}
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index bc9ba24859a..f0bef52d560 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -5,7 +5,7 @@ import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
-import { mergeDeep, pipe } from "remeda"
+import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
@@ -84,7 +84,7 @@ export namespace Config {
}
const promises: Promise[] = []
- for (const dir of directories) {
+ for (const dir of unique(directories)) {
await assertValid(dir)
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -246,7 +246,7 @@ export namespace Config {
}
}
- const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
+ const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record = {}
for await (const item of COMMAND_GLOB.scan({
@@ -284,7 +284,7 @@ export namespace Config {
return result
}
- const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
+ const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
async function loadAgent(dir: string) {
const result: Record = {}
@@ -327,7 +327,7 @@ export namespace Config {
return result
}
- const MODE_GLOB = new Bun.Glob("mode/*.md")
+ const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
async function loadMode(dir: string) {
const result: Record = {}
for await (const item of MODE_GLOB.scan({
@@ -356,7 +356,7 @@ export namespace Config {
return result
}
- const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
+ const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
@@ -508,6 +508,7 @@ export namespace Config {
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
+ permission_edit: z.string().optional().default("e").describe("Edit suggested changes before applying"),
session_compact: z.string().optional().default("c").describe("Compact the session"),
session_search: z.string().optional().default("ctrl+/").describe("Search in session messages"),
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
@@ -543,7 +544,7 @@ export namespace Config {
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
- input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
+ input_paste: z.string().optional().default("ctrl+v,ctrl+shift+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
@@ -663,6 +664,7 @@ export namespace Config {
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
+ cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({
@@ -678,7 +680,24 @@ export namespace Config {
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
- models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
+ models: z
+ .record(
+ z.string(),
+ ModelsDev.Model.partial().extend({
+ variants: z
+ .record(
+ z.string(),
+ z
+ .object({
+ disabled: z.boolean().optional().describe("Disable this variant for the model"),
+ })
+ .catchall(z.any()),
+ )
+ .optional()
+ .describe("Variant-specific configuration"),
+ }),
+ )
+ .optional(),
options: z
.object({
apiKey: z.string().optional(),
@@ -906,6 +925,12 @@ export namespace Config {
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
+ mcp_timeout: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
})
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 841f5f30517..7bc4382b019 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -265,106 +265,205 @@ export namespace Ripgrep {
}
}
+ /**
+ * Generates an indented tree view of files in a directory.
+ *
+ * Directories are listed before files at each level, both sorted alphabetically.
+ * Uses BFS traversal to ensure breadth-first coverage when truncating.
+ *
+ * @example
+ * ```
+ * src/
+ * components/
+ * Button.tsx
+ * Input.tsx
+ * [3 truncated]
+ * index.ts
+ * package.json
+ * ```
+ *
+ * @param input.cwd - The directory to scan
+ * @param input.limit - Max entries to include (default: 50). When exceeded,
+ * remaining siblings are collapsed into `[N truncated]` markers.
+ * @returns Newline-separated tree with tab indentation per depth level
+ */
export async function tree(input: { cwd: string; limit?: number }) {
log.info("tree", input)
+ const limit = input.limit ?? 50
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
- interface Node {
- path: string[]
- children: Node[]
- }
- function getPath(node: Node, parts: string[], create: boolean) {
- if (parts.length === 0) return node
- let current = node
- for (const part of parts) {
- let existing = current.children.find((x) => x.path.at(-1) === part)
- if (!existing) {
- if (!create) return
- existing = {
- path: current.path.concat(part),
- children: [],
- }
- current.children.push(existing)
+ /**
+ * Tree node with parent reference for ancestor traversal.
+ *
+ * Each node represents a file or directory. Directories have children,
+ * files don't. Uses Map for O(1) child lookup during tree construction
+ * (critical for repos with 40k+ files).
+ *
+ * The parent reference enables bottom-up selection: when we select a deep
+ * file, we automatically select all its ancestors so the path renders.
+ */
+ class FileNode {
+ readonly children: FileNode[] = []
+ private readonly lookup = new Map()
+ private sorted = false
+ selected = false
+
+ constructor(
+ readonly name: string = "",
+ readonly parent: FileNode | null = null,
+ ) {}
+
+ /**
+ * Gets an existing child by name, or creates it if it doesn't exist.
+ *
+ * Uses Map lookup for O(1) access. When creating, establishes the
+ * parent link so the child can propagate selection upward.
+ *
+ * @param name - The directory or file name (not a path)
+ * @returns The existing or newly created child node
+ */
+ child(name: string): FileNode {
+ let node = this.lookup.get(name)
+ if (!node) {
+ node = new FileNode(name, this)
+ this.children.push(node)
+ this.lookup.set(name, node)
}
- current = existing
+ return node
}
- return current
- }
- const root: Node = {
- path: [],
- children: [],
- }
- for (const file of files) {
- if (file.includes(".opencode")) continue
- const parts = file.split(path.sep)
- getPath(root, parts, true)
- }
+ /**
+ * Inserts a file path into the tree, creating intermediate directories.
+ *
+ * @example
+ * root.insert(["src", "utils", "format.ts"])
+ * // Creates: root -> src/ -> utils/ -> format.ts
+ *
+ * @param parts - Path segments from root to file
+ */
+ insert(parts: string[]): void {
+ let node: FileNode = this
+ for (const part of parts) node = node.child(part)
+ }
- function sort(node: Node) {
- node.children.sort((a, b) => {
- if (!a.children.length && b.children.length) return 1
- if (!b.children.length && a.children.length) return -1
- return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
- })
- for (const child of node.children) {
- sort(child)
+ /**
+ * Sorts children: directories first, then alphabetically.
+ *
+ * Lazy - only sorts once per node. Called during BFS traversal,
+ * so we only sort nodes we actually visit. For a 40k file repo
+ * with limit=200, this saves sorting thousands of unvisited nodes.
+ */
+ sort(): void {
+ if (this.sorted) return
+ this.children.sort((a, b) => {
+ if (a.isDir !== b.isDir) return b.isDir ? 1 : -1
+ return a.name.localeCompare(b.name)
+ })
+ this.sorted = true
}
- }
- sort(root)
- let current = [root]
- const result: Node = {
- path: [],
- children: [],
- }
+ /** A node is a directory if it has children (files are leaves). */
+ get isDir(): boolean {
+ return this.children.length > 0
+ }
- let processed = 0
- const limit = input.limit ?? 50
- while (current.length > 0) {
- const next = []
- for (const node of current) {
- if (node.children.length) next.push(...node.children)
+ /**
+ * Marks this node for rendering, propagating up to ancestors.
+ *
+ * Called during BFS when this node is chosen within the limit.
+ * Recursively selects the parent chain so the full path renders.
+ *
+ * @example
+ * // Selecting "format.ts" also selects "utils/" and "src/"
+ * formatNode.select()
+ * // Now: root.selected=true, src.selected=true,
+ * // utils.selected=true, format.selected=true
+ */
+ select(): void {
+ this.selected = true
+ this.parent?.select()
}
- const max = Math.max(...current.map((x) => x.children.length))
- for (let i = 0; i < max && processed < limit; i++) {
- for (const node of current) {
- const child = node.children[i]
- if (!child) continue
- getPath(result, child.path, true)
- processed++
- if (processed >= limit) break
+
+ /**
+ * Renders this subtree as an indented string.
+ *
+ * Only renders selected nodes. Appends "/" to directories.
+ * Shows "[N truncated]" for directories with unselected children,
+ * so users know there's more content they're not seeing.
+ *
+ * @param indentLevel - Current indentation level (0 for root's children)
+ * @returns Newline-separated tree with tab indentation
+ */
+ render(indentLevel = 0): string {
+ if (!this.selected) return ""
+
+ const outputLines: string[] = []
+ // Root node has no name, so children stay at same indent level
+ const childIndentLevel = this.name ? indentLevel + 1 : indentLevel
+
+ if (this.name) {
+ outputLines.push("\t".repeat(indentLevel) + this.name + (this.isDir ? "/" : ""))
}
- }
- if (processed >= limit) {
- for (const node of [...current, ...next]) {
- const compare = getPath(result, node.path, false)
- if (!compare) continue
- if (compare?.children.length !== node.children.length) {
- const diff = node.children.length - compare.children.length
- compare.children.push({
- path: compare.path.concat(`[${diff} truncated]`),
- children: [],
- })
- }
+
+ for (const child of this.children) {
+ const renderedChild = child.render(childIndentLevel)
+ if (renderedChild) outputLines.push(renderedChild)
}
- break
+
+ const unselectedChildCount = this.children.filter((c) => !c.selected).length
+ if (unselectedChildCount > 0) {
+ outputLines.push("\t".repeat(childIndentLevel) + `[${unselectedChildCount} truncated]`)
+ }
+
+ return outputLines.join("\n")
}
- current = next
}
- const lines: string[] = []
+ // Build complete tree from file list
+ const root = new FileNode()
+ for (const file of files) {
+ if (!file.includes(".opencode")) {
+ root.insert(file.split(path.sep))
+ }
+ }
- function render(node: Node, depth: number) {
- const indent = "\t".repeat(depth)
- lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
- for (const child of node.children) {
- render(child, depth + 1)
+ // Select up to `limit` entries using BFS with round-robin.
+ //
+ // Why BFS? Ensures we show top-level structure before diving deep.
+ // A repo with src/, docs/, tests/ should show all three before
+ // showing src/components/Button/styles/...
+ //
+ // Why round-robin? Distributes selection evenly across siblings.
+ // Instead of showing all of src/'s children before any of docs/,
+ // we alternate: src/index.ts, docs/README.md, src/utils.ts, docs/api.md...
+ // This gives a balanced view of the entire repo structure.
+ let selectedCount = 0
+ let nodesAtCurrentDepth: FileNode[] = [root]
+
+ while (nodesAtCurrentDepth.length > 0 && selectedCount < limit) {
+ // Collect all children for the next BFS depth level
+ const nodesAtNextDepth: FileNode[] = []
+ for (const parent of nodesAtCurrentDepth) {
+ parent.sort()
+ nodesAtNextDepth.push(...parent.children)
}
+
+ // Round-robin: take 1st child from each parent, then 2nd from each, etc.
+ // This ensures fair distribution across all branches at this depth.
+ const mostChildrenAnyParentHas = Math.max(0, ...nodesAtCurrentDepth.map((n) => n.children.length))
+ roundRobin: for (let childIndex = 0; childIndex < mostChildrenAnyParentHas; childIndex++) {
+ for (const parent of nodesAtCurrentDepth) {
+ const child = parent.children[childIndex]
+ if (!child) continue
+ child.select() // Also selects ancestors via parent chain
+ if (++selectedCount >= limit) break roundRobin
+ }
+ }
+
+ nodesAtCurrentDepth = nodesAtNextDepth
}
- result.children.map((x) => render(x, 0))
- return lines.join("\n")
+ return root.render()
}
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index dedc50fee34..cf68944a330 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -331,3 +331,12 @@ export const nixfmt: Info = {
return Bun.which("nixfmt") !== null
},
}
+
+export const rustfmt: Info = {
+ name: "rustfmt",
+ command: ["rustfmt", "$FILE"],
+ extensions: [".rs"],
+ async enabled() {
+ return Bun.which("rustfmt") !== null
+ },
+}
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 8ee54a4bf82..10a0636675f 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -4,7 +4,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
-import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
+import {
+ CallToolResultSchema,
+ type Tool as MCPToolDef,
+ ToolListChangedNotificationSchema,
+} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
@@ -93,7 +97,7 @@ export namespace MCP {
}
// Convert MCP tool definition to AI SDK Tool type
- function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
+ async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise {
const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object"
@@ -103,15 +107,23 @@ export namespace MCP {
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
+ const config = await Config.get()
return dynamicTool({
description: mcpTool.description ?? "",
inputSchema: jsonSchema(schema),
execute: async (args: unknown) => {
- return client.callTool({
- name: mcpTool.name,
- arguments: args as Record,
- })
+ return client.callTool(
+ {
+ name: mcpTool.name,
+ arguments: args as Record,
+ },
+ CallToolResultSchema,
+ {
+ resetTimeoutOnProgress: true,
+ timeout: config.experimental?.mcp_timeout,
+ },
+ )
},
})
}
@@ -120,6 +132,9 @@ export namespace MCP {
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map()
+ // Prompt cache types
+ type PromptInfo = Awaited>["prompts"][number]
+
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -164,6 +179,29 @@ export namespace MCP {
},
)
+ // Helper function to fetch prompts for a specific client
+ async function fetchPromptsForClient(clientName: string, client: Client) {
+ const prompts = await client.listPrompts().catch((e) => {
+ log.error("failed to get prompts", { clientName, error: e.message })
+ return undefined
+ })
+
+ if (!prompts) {
+ return
+ }
+
+ const commands: Record = {}
+
+ for (const prompt of prompts.prompts) {
+ const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+ const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+ const key = sanitizedClientName + ":" + sanitizedPromptName
+
+ commands[key] = { ...prompt, client: clientName }
+ }
+ return commands
+ }
+
export async function add(name: string, mcp: Config.Mcp) {
const s = await state()
const result = await create(name, mcp)
@@ -474,12 +512,61 @@ export namespace MCP {
for (const mcpTool of toolsResult.tools) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
- result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
+ result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
}
}
return result
}
+ export async function prompts() {
+ const s = await state()
+ const clientsSnapshot = await clients()
+
+ const prompts = Object.fromEntries(
+ (
+ await Promise.all(
+ Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
+ if (s.status[clientName]?.status !== "connected") {
+ return []
+ }
+
+ return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
+ }),
+ )
+ ).flat(),
+ )
+
+ return prompts
+ }
+
+ export async function getPrompt(clientName: string, name: string, args?: Record) {
+ const clientsSnapshot = await clients()
+ const client = clientsSnapshot[clientName]
+
+ if (!client) {
+ log.warn("client not found for prompt", {
+ clientName,
+ })
+ return undefined
+ }
+
+ const result = await client
+ .getPrompt({
+ name: name,
+ arguments: args,
+ })
+ .catch((e) => {
+ log.error("failed to get prompt from MCP server", {
+ clientName,
+ promptName: name,
+ error: e.message,
+ })
+ return undefined
+ })
+
+ return result
+ }
+
/**
* Start OAuth authentication flow for an MCP server.
* Returns the authorization URL that should be opened in a browser.
diff --git a/packages/opencode/src/permission/editor.ts b/packages/opencode/src/permission/editor.ts
new file mode 100644
index 00000000000..9540b10fe84
--- /dev/null
+++ b/packages/opencode/src/permission/editor.ts
@@ -0,0 +1,78 @@
+import * as path from "path"
+import { createTwoFilesPatch } from "diff"
+import type { Permission } from "./index"
+import { Text } from "../util/text"
+
+export namespace PermissionEditor {
+ // Metadata shape for single-file edits (edit tool)
+ export interface SingleFileMetadata {
+ filePath: string
+ originalContent: string
+ suggestedContent: string
+ }
+
+ // Response data sent back through permission system
+ export interface SingleFileModifyData {
+ content: string
+ }
+
+ /**
+ * Check if permission supports single-file editing (edit tool)
+ */
+ export function canEdit(permission: Permission.Info): boolean {
+ if (permission.type !== "edit") return false
+ const m = permission.metadata
+ return (
+ typeof m?.filePath === "string" &&
+ typeof m?.originalContent === "string" &&
+ typeof m?.suggestedContent === "string"
+ )
+ }
+
+ /**
+ * Check if permission supports editing
+ */
+ export function isEditable(permission: Permission.Info): boolean {
+ return canEdit(permission)
+ }
+
+ /**
+ * Get content to edit for single-file permission
+ */
+ export function getContent(permission: Permission.Info): string {
+ return permission.metadata.suggestedContent as string
+ }
+
+ /**
+ * Get file extension for syntax highlighting in editor
+ */
+ export function getExtension(permission: Permission.Info): string {
+ return path.extname(permission.metadata.filePath as string) || ".txt"
+ }
+
+ /**
+ * Calculate starting line number (first changed line) for editor positioning
+ */
+ export function getStartLine(original: string, suggested: string): number {
+ return Text.getFirstDifferingLine(original, suggested)
+ }
+
+ /**
+ * Check if edited content differs from suggestion
+ */
+ export function hasChanges(suggested: string, edited: string): boolean {
+ return Text.hasChanges(suggested, edited)
+ }
+
+ /**
+ * Compute unified diff for display
+ */
+ export function computeDiff(filePath: string, original: string, modified: string): string {
+ return createTwoFilesPatch(
+ filePath,
+ filePath,
+ Text.normalizeLineEndings(original),
+ Text.normalizeLineEndings(modified),
+ )
+ }
+}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index f3a5ac4eb23..0df6b1d5216 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -50,13 +50,21 @@ export namespace Permission {
),
}
+ /**
+ * Result returned from Permission.ask() when user responds.
+ * Contains optional modified data when user edits before accepting.
+ */
+ export interface AskResult {
+ modified?: T
+ }
+
const state = Instance.state(
() => {
const pending: {
[sessionID: string]: {
[permissionID: string]: {
info: Info
- resolve: () => void
+ resolve: (result?: AskResult) => void
reject: (e: any) => void
onRespond?: (response: Response) => void
}
@@ -98,7 +106,7 @@ export namespace Permission {
return result.sort((a, b) => a.id.localeCompare(b.id))
}
- export async function ask(input: {
+ export async function ask(input: {
type: Info["type"]
title: Info["title"]
pattern?: Info["pattern"]
@@ -108,7 +116,7 @@ export namespace Permission {
metadata: Info["metadata"]
onSetup?: (info: Info) => void
onRespond?: (response: Response) => void
- }) {
+ }): Promise | undefined> {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
@@ -145,10 +153,10 @@ export namespace Permission {
}
pending[input.sessionID] = pending[input.sessionID] || {}
- return new Promise((resolve, reject) => {
+ return new Promise | undefined>((resolve, reject) => {
pending[input.sessionID][info.id] = {
info,
- resolve,
+ resolve: resolve as (result?: AskResult) => void,
reject,
onRespond: input.onRespond,
}
@@ -157,10 +165,15 @@ export namespace Permission {
})
}
- export const Response = z.enum(["once", "always", "reject"])
+ export const Response = z.enum(["once", "always", "reject", "modify"])
export type Response = z.infer
- export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
+ export function respond(input: {
+ sessionID: Info["sessionID"]
+ permissionID: Info["id"]
+ response: Response
+ modifyData?: unknown
+ }) {
log.info("response", input)
const { pending, approved } = state()
const match = pending[input.sessionID]?.[input.permissionID]
@@ -176,6 +189,10 @@ export namespace Permission {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
+ if (input.response === "modify") {
+ match.resolve({ modified: input.modifyData })
+ return
+ }
match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index ac2976161cd..d80996636a2 100644
--- a/packages/opencode/src/provider/models.ts
+++ b/packages/opencode/src/provider/models.ts
@@ -60,6 +60,7 @@ export namespace ModelsDev {
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
+ variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 7a31f7f1b86..cf2cef35164 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1,7 +1,7 @@
import z from "zod"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
-import { mapValues, mergeDeep, sortBy } from "remeda"
+import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
@@ -34,6 +34,7 @@ import { createCohere } from "@ai-sdk/cohere"
import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
+import { createVercel } from "@ai-sdk/vercel"
import { ProviderTransform } from "./transform"
export namespace Provider {
@@ -58,6 +59,7 @@ export namespace Provider {
"@ai-sdk/gateway": createGateway,
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
+ "@ai-sdk/vercel": createVercel,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -424,16 +426,6 @@ export namespace Provider {
},
}
- export const Variant = z
- .object({
- disabled: z.boolean(),
- })
- .catchall(z.any())
- .meta({
- ref: "Variant",
- })
- export type Variant = z.infer
-
export const Model = z
.object({
id: z.string(),
@@ -497,7 +489,7 @@ export namespace Provider {
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()),
release_date: z.string(),
- variants: z.record(z.string(), Variant).optional(),
+ variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
.meta({
ref: "Model",
@@ -580,7 +572,7 @@ export namespace Provider {
variants: {},
}
- m.variants = mapValues(ProviderTransform.variants(m), (v) => ({ disabled: false, ...v }))
+ m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
return m
}
@@ -716,7 +708,13 @@ export namespace Provider {
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
+ variants: {},
}
+ const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
+ parsedModel.variants = mapValues(
+ pickBy(merged, (v) => !v.disabled),
+ (v) => omit(v, ["disabled"]),
+ )
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
@@ -841,6 +839,16 @@ export namespace Provider {
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
)
delete provider.models[modelID]
+
+ // Filter out disabled variants from config
+ const configVariants = configProvider?.models?.[modelID]?.variants
+ if (configVariants && model.variants) {
+ const merged = mergeDeep(model.variants, configVariants)
+ model.variants = mapValues(
+ pickBy(merged, (v) => !v.disabled),
+ (v) => omit(v, ["disabled"]),
+ )
+ }
}
if (Object.keys(provider.models).length === 0) {
@@ -1029,6 +1037,7 @@ export namespace Provider {
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
+ "gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 9de2ad52c5d..2701dc2ccd9 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -3,6 +3,7 @@ import { unique } from "remeda"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
+import { iife } from "@/util/iife"
type Modality = NonNullable["input"][number]
@@ -229,7 +230,6 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 1
if (id.includes("minimax-m2")) {
- if (id.includes("m2.1")) return 0.9
return 0.95
}
if (id.includes("gemini")) return 0.95
@@ -238,7 +238,10 @@ export namespace ProviderTransform {
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
- if (id.includes("minimax-m2")) return 20
+ if (id.includes("minimax-m2")) {
+ if (id.includes("m2.1")) return 40
+ return 20
+ }
if (id.includes("gemini")) return 64
return undefined
}
@@ -246,7 +249,7 @@ export namespace ProviderTransform {
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
- export function variants(model: Provider.Model) {
+ export function variants(model: Provider.Model): Record> {
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
@@ -292,13 +295,17 @@ export namespace ProviderTransform {
case "@ai-sdk/openai":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
if (id === "gpt-5-pro") return {}
- const openaiEfforts = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
- if (model.release_date >= "2025-11-13") {
- openaiEfforts.unshift("none")
- }
- if (model.release_date >= "2025-12-04") {
- openaiEfforts.push("xhigh")
- }
+ const openaiEfforts = iife(() => {
+ if (model.id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
+ const arr = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
+ if (model.release_date >= "2025-11-13") {
+ arr.unshift("none")
+ }
+ if (model.release_date >= "2025-12-04") {
+ arr.push("xhigh")
+ }
+ return arr
+ })
return Object.fromEntries(
openaiEfforts.map((effort) => [
effort,
diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts
new file mode 100644
index 00000000000..b791520668c
--- /dev/null
+++ b/packages/opencode/src/server/cors.ts
@@ -0,0 +1,27 @@
+/**
+ * Checks if the given origin is allowed by the CORS policy.
+ * @param origin - The origin header value from the request
+ * @returns The origin string if allowed, undefined otherwise
+ */
+export function isOriginAllowed(origin: string | undefined): string | undefined {
+ if (!origin) return undefined
+
+ // localhost (http only, any port)
+ if (origin.startsWith("http://localhost:")) return origin
+ if (origin.startsWith("http://127.0.0.1:")) return origin
+
+ // Tauri desktop origins
+ if (origin === "tauri://localhost" || origin === "http://tauri.localhost") return origin
+
+ // *.opencode.ai (https only)
+ if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(origin)) {
+ return origin
+ }
+
+ // *.shuv.ai (https only) - fork's hosted domain
+ if (/^https:\/\/([a-z0-9-]+\.)*shuv\.ai$/.test(origin)) {
+ return origin
+ }
+
+ return undefined
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 63ef1b647f9..0c5cff6a00f 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1,3 +1,4 @@
+import { isOriginAllowed } from "./cors"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
@@ -64,6 +65,7 @@ export namespace Server {
const log = Log.create({ service: "server" })
let _url: URL | undefined
+ let _corsWhitelist: string[] = []
export function url(): URL {
return _url ?? new URL("http://localhost:4096")
@@ -113,19 +115,7 @@ export namespace Server {
})
.use(
cors({
- origin(input) {
- if (!input) return
-
- if (input.startsWith("http://localhost:")) return input
- if (input.startsWith("http://127.0.0.1:")) return input
- if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
-
- // *.opencode.ai (https only, adjust if needed)
- if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
- return input
- }
- return
- },
+ origin: isOriginAllowed,
}),
)
.get(
@@ -1593,15 +1583,18 @@ export namespace Server {
permissionID: z.string(),
}),
),
- validator("json", z.object({ response: Permission.Response })),
+ validator(
+ "json",
+ z.object({ response: Permission.Response, modifyData: z.object({ content: z.string() }).optional() }),
+ ),
async (c) => {
const params = c.req.valid("param")
- const sessionID = params.sessionID
- const permissionID = params.permissionID
+ const body = c.req.valid("json")
Permission.respond({
- sessionID,
- permissionID,
- response: c.req.valid("json").response,
+ sessionID: params.sessionID,
+ permissionID: params.permissionID,
+ response: body.response,
+ modifyData: body.modifyData,
})
return c.json(true)
},
@@ -2877,7 +2870,9 @@ export namespace Server {
return result
}
- export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
+ export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+ _corsWhitelist = opts.cors ?? []
+
const args = {
hostname: opts.hostname,
idleTimeout: 0,
@@ -2903,7 +2898,7 @@ export namespace Server {
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
- MDNS.publish(server.port!)
+ MDNS.publish(server.port!, `opencode-${server.port!}`)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 0736a1f9eba..ccd7af1f0f5 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -82,13 +82,14 @@ export namespace LLM {
}
const provider = await Provider.getProvider(input.model.providerID)
- const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : undefined
+ const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
+ const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const options = pipe(
ProviderTransform.options(input.model, input.sessionID, provider.options),
- mergeDeep(input.small ? ProviderTransform.smallOptions(input.model) : {}),
+ mergeDeep(small),
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
- mergeDeep(variant && !variant.disabled ? variant : {}),
+ mergeDeep(variant),
)
const params = await Plugin.trigger(
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index bb78ae64ce6..171ab6937e5 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -161,6 +161,19 @@ export namespace MessageV2 {
description: z.string(),
agent: z.string(),
command: z.string().optional(),
+ model: z
+ .object({
+ providerID: z.string(),
+ modelID: z.string(),
+ })
+ .optional(),
+ parentAgent: z.string().optional(),
+ parentModel: z
+ .object({
+ providerID: z.string(),
+ modelID: z.string(),
+ })
+ .optional(),
})
export type SubtaskPart = z.infer
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 78871630c65..e77397a4d6b 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -186,7 +186,7 @@ export namespace SessionProcessor {
...match,
state: {
status: "completed",
- input: value.input,
+ input: value.output.modifiedInput ?? value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 40c44f2d07f..95309afc533 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -90,6 +90,7 @@ export namespace SessionPrompt {
noReply: z.boolean().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
system: z.string().optional(),
+ variant: z.string().optional(),
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
@@ -283,139 +284,149 @@ export namespace SessionPrompt {
})
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
- const task = tasks.pop()
- // pending subtask
+ const subtasks = tasks.filter((t): t is Extract => t.type === "subtask")
+ const otherTasks = tasks.filter((t) => t.type !== "subtask")
+ tasks.length = 0
+ tasks.push(...otherTasks)
+
+ // pending subtasks
// TODO: centralize "invoke tool" logic
- if (task?.type === "subtask") {
+ if (subtasks.length > 0) {
const taskTool = await TaskTool.init()
- const assistantMessage = (await Session.updateMessage({
- id: Identifier.ascending("message"),
- role: "assistant",
- parentID: lastUser.id,
- sessionID,
- mode: task.agent,
- agent: task.agent,
- path: {
- cwd: Instance.directory,
- root: Instance.worktree,
- },
- cost: 0,
- tokens: {
- input: 0,
- output: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- modelID: model.id,
- providerID: model.providerID,
- time: {
- created: Date.now(),
- },
- })) as MessageV2.Assistant
- let part = (await Session.updatePart({
- id: Identifier.ascending("part"),
- messageID: assistantMessage.id,
- sessionID: assistantMessage.sessionID,
- type: "tool",
- callID: ulid(),
- tool: TaskTool.id,
- state: {
- status: "running",
- input: {
- prompt: task.prompt,
- description: task.description,
- subagent_type: task.agent,
- command: task.command,
+
+ const executeSubtask = async (task: (typeof subtasks)[0]) => {
+ const assistantMessage = (await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "assistant",
+ parentID: lastUser.id,
+ sessionID,
+ mode: task.agent,
+ agent: task.agent,
+ path: {
+ cwd: Instance.directory,
+ root: Instance.worktree,
},
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: model.id,
+ providerID: model.providerID,
time: {
- start: Date.now(),
+ created: Date.now(),
},
- },
- })) as MessageV2.ToolPart
- const taskArgs = {
- prompt: task.prompt,
- description: task.description,
- subagent_type: task.agent,
- command: task.command,
- }
- await Plugin.trigger(
- "tool.execute.before",
- {
- tool: "task",
- sessionID,
- callID: part.id,
- },
- { args: taskArgs },
- )
- let executionError: Error | undefined
- const result = await taskTool
- .execute(taskArgs, {
- agent: task.agent,
+ })) as MessageV2.Assistant
+ let part = (await Session.updatePart({
+ id: Identifier.ascending("part"),
messageID: assistantMessage.id,
- sessionID: sessionID,
- abort,
- async metadata(input) {
- await Session.updatePart({
- ...part,
- type: "tool",
- state: {
- ...part.state,
- ...input,
- },
- } satisfies MessageV2.ToolPart)
- },
- })
- .catch((error) => {
- executionError = error
- log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
- return undefined
- })
- await Plugin.trigger(
- "tool.execute.after",
- {
- tool: "task",
- sessionID,
- callID: part.id,
- },
- result,
- )
- assistantMessage.finish = "tool-calls"
- assistantMessage.time.completed = Date.now()
- await Session.updateMessage(assistantMessage)
- if (result && part.state.status === "running") {
- await Session.updatePart({
- ...part,
+ sessionID: assistantMessage.sessionID,
+ type: "tool",
+ callID: ulid(),
+ tool: TaskTool.id,
state: {
- status: "completed",
- input: part.state.input,
- title: result.title,
- metadata: result.metadata,
- output: result.output,
- attachments: result.attachments,
+ status: "running",
+ input: {
+ prompt: task.prompt,
+ description: task.description,
+ subagent_type: task.agent,
+ command: task.command,
+ },
time: {
- ...part.state.time,
- end: Date.now(),
+ start: Date.now(),
},
},
- } satisfies MessageV2.ToolPart)
- }
- if (!result) {
- await Session.updatePart({
- ...part,
- state: {
- status: "error",
- 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(),
+ })) as MessageV2.ToolPart
+ const taskArgs = {
+ prompt: task.prompt,
+ description: task.description,
+ subagent_type: task.agent,
+ command: task.command,
+ }
+ await Plugin.trigger(
+ "tool.execute.before",
+ {
+ tool: "task",
+ sessionID,
+ callID: part.id,
+ },
+ { args: taskArgs },
+ )
+ let executionError: Error | undefined
+ const result = await taskTool
+ .execute(taskArgs, {
+ agent: task.agent,
+ messageID: assistantMessage.id,
+ sessionID: sessionID,
+ abort,
+ extra: { model: task.model },
+ async metadata(input) {
+ await Session.updatePart({
+ ...part,
+ type: "tool",
+ state: {
+ ...part.state,
+ ...input,
+ },
+ } satisfies MessageV2.ToolPart)
},
- metadata: part.metadata,
- input: part.state.input,
+ })
+ .catch((error) => {
+ executionError = error
+ log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
+ return undefined
+ })
+ await Plugin.trigger(
+ "tool.execute.after",
+ {
+ tool: "task",
+ sessionID,
+ callID: part.id,
},
- } satisfies MessageV2.ToolPart)
+ result,
+ )
+ assistantMessage.finish = "tool-calls"
+ assistantMessage.time.completed = Date.now()
+ await Session.updateMessage(assistantMessage)
+ if (result && part.state.status === "running") {
+ await Session.updatePart({
+ ...part,
+ state: {
+ status: "completed",
+ input: part.state.input,
+ title: result.title,
+ metadata: result.metadata,
+ output: result.output,
+ attachments: result.attachments,
+ time: {
+ ...part.state.time,
+ end: Date.now(),
+ },
+ },
+ } satisfies MessageV2.ToolPart)
+ }
+ if (!result) {
+ await Session.updatePart({
+ ...part,
+ state: {
+ status: "error",
+ 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(),
+ },
+ metadata: part.metadata,
+ input: part.state.input,
+ },
+ } satisfies MessageV2.ToolPart)
+ }
}
+ await Promise.all(subtasks.map(executeSubtask))
+
// Add synthetic user message to prevent certain reasoning models from erroring
// If we create assistant messages w/ out user ones following mid loop thinking signatures
// will be missing and it can cause errors for models like gemini for example
@@ -426,8 +437,8 @@ export namespace SessionPrompt {
time: {
created: Date.now(),
},
- agent: lastUser.agent,
- model: lastUser.model,
+ agent: subtasks[0]?.parentAgent ?? lastUser.agent,
+ model: subtasks[0]?.parentModel ?? lastUser.model,
}
await Session.updateMessage(summaryUserMsg)
await Session.updatePart({
@@ -442,6 +453,8 @@ export namespace SessionPrompt {
continue
}
+ const task = otherTasks.pop()
+
// pending compaction
if (task?.type === "compaction") {
const result = await SessionCompaction.process({
@@ -564,9 +577,8 @@ export namespace SessionPrompt {
async function lastModel(sessionID: string) {
for await (const item of MessageV2.stream(sessionID)) {
- if (item.info.role === "user" && item.info.model) return item.info.model
+ if (item.info.role === "user" && item.info.model?.modelID) return item.info.model
}
- return Provider.defaultModel()
}
async function resolveTools(input: {
@@ -607,7 +619,7 @@ export namespace SessionPrompt {
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
- extra: { model: input.model },
+ extra: {},
agent: input.agent.name,
metadata: async (val) => {
const match = input.processor.partFromToolCall(options.toolCallId)
@@ -725,8 +737,9 @@ export namespace SessionPrompt {
},
tools: input.tools,
agent: agent.name,
- model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
+ model: input.model ?? agent.model ?? (await lastModel(input.sessionID)) ?? (await Provider.defaultModel()),
system: input.system,
+ variant: input.variant,
}
const parts = await Promise.all(
@@ -1053,7 +1066,7 @@ export namespace SessionPrompt {
SessionRevert.cleanup(session)
}
const agent = await Agent.get(input.agent)
- const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
+ const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) ?? (await Provider.defaultModel())
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
@@ -1271,6 +1284,7 @@ export namespace SessionPrompt {
model: z.string().optional(),
arguments: z.string(),
command: z.string(),
+ variant: z.string().optional(),
})
export type CommandInput = z.infer
const bashRegex = /!`([^`]+)`/g
@@ -1362,7 +1376,9 @@ export namespace SessionPrompt {
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
- const placeholders = command.template.match(placeholderRegex) ?? []
+ const templateCommand = await command.template
+
+ const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
@@ -1370,7 +1386,7 @@ export namespace SessionPrompt {
}
// Let the final placeholder swallow any extra arguments so prompts read naturally
- const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
+ const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
@@ -1395,6 +1411,7 @@ export namespace SessionPrompt {
}
template = template.trim()
+ const sessionModel = await lastModel(input.sessionID)
const model = await (async () => {
if (command.model) {
return Provider.parseModel(command.model)
@@ -1406,7 +1423,7 @@ export namespace SessionPrompt {
}
}
if (input.model) return Provider.parseModel(input.model)
- return await lastModel(input.sessionID)
+ return sessionModel ?? (await Provider.defaultModel())
})()
try {
@@ -1423,6 +1440,8 @@ export namespace SessionPrompt {
throw e
}
const agent = await Agent.get(agentName)
+ const parentAgent = input.agent ?? "build"
+ const parentModel = input.model ? Provider.parseModel(input.model) : sessionModel
const parts =
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
@@ -1432,18 +1451,32 @@ export namespace SessionPrompt {
agent: agent.name,
description: command.description ?? "",
command: input.command,
+ model: { providerID: model.providerID, modelID: model.modelID },
+ parentAgent,
+ parentModel,
// TODO: how can we make task tool accept a more complex input?
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
},
]
: await resolvePromptParts(template)
+ await Plugin.trigger(
+ "command.execute.before",
+ {
+ command: input.command,
+ sessionID: input.sessionID,
+ arguments: input.arguments,
+ },
+ { parts },
+ )
+
const result = (await prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model,
agent: agentName,
parts,
+ variant: input.variant,
})) as MessageV2.WithParts
Bus.publish(Command.Event.Executed, {
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index fa6fd7e43e7..bf90dd5870c 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -35,7 +35,7 @@ export namespace Skill {
}),
)
- const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
+ const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
export const state = Instance.state(async () => {
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 129a3b811cd..6fb2f5714d3 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -9,6 +9,7 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import { Permission } from "../permission"
+import type { PermissionEditor } from "../permission/editor"
import DESCRIPTION from "./edit.txt"
import { File } from "../file"
import { Bus } from "../bus"
@@ -18,13 +19,10 @@ import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
import { Snapshot } from "@/snapshot"
import { Ide } from "../ide"
+import { Text } from "../util/text"
const MAX_DIAGNOSTICS_PER_FILE = 20
-function normalizeLineEndings(text: string): string {
- return text.replaceAll("\r\n", "\n")
-}
-
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@@ -77,7 +75,9 @@ export const EditTool = Tool.define("edit", {
let diff = ""
let contentOld = ""
let contentNew = ""
- // Resolve permission for this specific file
+ let userModified = false
+
+ // Resolve permission for this specific file (supports glob patterns)
const resolvedPermission = Agent.resolveFilePermission({
permission: agent.permission.edit,
filePath,
@@ -98,15 +98,18 @@ export const EditTool = Tool.define("edit", {
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
contentNew = params.newString
- diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (resolvedPermission === "ask") {
- await Permission.ask({
+ const result = await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Edit this file: " + filePath,
- metadata: { filePath, diff },
+ metadata: {
+ filePath,
+ originalContent: contentOld,
+ suggestedContent: contentNew,
+ },
onSetup: (info) => {
if (Ide.active()) {
Ide.openDiff(filePath, contentNew).then((response) => {
@@ -124,8 +127,13 @@ export const EditTool = Tool.define("edit", {
}
},
})
+ if (result?.modified?.content !== undefined) {
+ contentNew = result.modified.content
+ userModified = true
+ }
}
- await Bun.write(filePath, params.newString)
+ diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
+ await Bun.write(filePath, contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
@@ -141,17 +149,18 @@ export const EditTool = Tool.define("edit", {
contentOld = await file.text()
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
- diff = trimDiff(
- createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
- )
if (resolvedPermission === "ask") {
- await Permission.ask({
+ const result = await Permission.ask({
type: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
callID: ctx.callID,
title: "Edit this file: " + filePath,
- metadata: { filePath, diff },
+ metadata: {
+ filePath,
+ originalContent: contentOld,
+ suggestedContent: contentNew,
+ },
onSetup: (info) => {
if (Ide.active()) {
Ide.openDiff(filePath, contentNew).then((response) => {
@@ -169,6 +178,10 @@ export const EditTool = Tool.define("edit", {
}
},
})
+ if (result?.modified?.content !== undefined) {
+ contentNew = result.modified.content
+ userModified = true
+ }
}
await file.write(contentNew)
@@ -177,12 +190,21 @@ export const EditTool = Tool.define("edit", {
})
contentNew = await file.text()
diff = trimDiff(
- createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
+ createTwoFilesPatch(
+ filePath,
+ filePath,
+ Text.normalizeLineEndings(contentOld),
+ Text.normalizeLineEndings(contentNew),
+ ),
)
FileTime.read(ctx.sessionID, filePath)
})
let output = ""
+ if (userModified) {
+ output +=
+ "Note: The user modified this edit before accepting. The file now contains the user's version, not your original suggestion. Do not attempt to change it back to your original suggestion.\n"
+ }
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
@@ -215,6 +237,7 @@ export const EditTool = Tool.define("edit", {
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
+ modifiedInput: userModified ? { ...params, newString: contentNew } : undefined,
}
},
})
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index bc7958889a6..a895b6a6946 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -10,6 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
+import { Provider } from "../provider/provider"
export { DESCRIPTION as TASK_DESCRIPTION }
@@ -78,10 +79,11 @@ export const TaskTool = Tool.define("task", async () => {
})
})
- const model = agent.model ?? {
- modelID: msg.info.modelID,
- providerID: msg.info.providerID,
- }
+ const defaultModel = await Provider.defaultModel()
+ const extraModel = ctx.extra?.model?.modelID ? ctx.extra.model : undefined
+ const agentModel = agent.model?.modelID ? agent.model : undefined
+ const msgModel = msg.info.modelID ? { modelID: msg.info.modelID, providerID: msg.info.providerID } : undefined
+ const model = extraModel ?? agentModel ?? msgModel ?? defaultModel
function cancel() {
SessionPrompt.cancel(session.id)
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index acee24902c1..9da08f8a33c 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -33,6 +33,7 @@ export namespace Tool {
metadata: M
output: string
attachments?: MessageV2.FilePart[]
+ modifiedInput?: z.infer
}>
formatValidationError?(error: z.ZodError): string
}>
diff --git a/packages/opencode/src/util/text.ts b/packages/opencode/src/util/text.ts
new file mode 100644
index 00000000000..6c6708839ff
--- /dev/null
+++ b/packages/opencode/src/util/text.ts
@@ -0,0 +1,28 @@
+export namespace Text {
+ /**
+ * Normalize line endings from CRLF to LF
+ */
+ export function normalizeLineEndings(text: string): string {
+ return text.replace(/\r\n/g, "\n")
+ }
+
+ /**
+ * Find the first line number (1-indexed) where original and modified differ.
+ * Returns 1 if no differences found or if modified has new content.
+ */
+ export function getFirstDifferingLine(original: string, modified: string): number {
+ const originalLines = original.split("\n")
+ const modifiedLines = modified.split("\n")
+ for (let i = 0; i < modifiedLines.length; i++) {
+ if (originalLines[i] !== modifiedLines[i]) return i + 1
+ }
+ return 1
+ }
+
+ /**
+ * Check if two strings differ (after normalizing line endings)
+ */
+ export function hasChanges(a: string, b: string): boolean {
+ return normalizeLineEndings(a) !== normalizeLineEndings(b)
+ }
+}
diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts
new file mode 100644
index 00000000000..4a29f370dfd
--- /dev/null
+++ b/packages/opencode/test/file/ripgrep.test.ts
@@ -0,0 +1,120 @@
+import { test, expect } from "bun:test"
+import { Ripgrep } from "../../src/file/ripgrep"
+import { tmpdir } from "../fixture/fixture"
+import * as fs from "fs/promises"
+import path from "path"
+
+test("tree returns empty for empty directory", async () => {
+ await using tmp = await tmpdir()
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe("")
+})
+
+test("tree returns single file", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.writeFile(path.join(dir, "index.ts"), "export {}")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe("index.ts")
+})
+
+test("tree returns flat file list sorted alphabetically", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.writeFile(path.join(dir, "zebra.ts"), "")
+ await fs.writeFile(path.join(dir, "apple.ts"), "")
+ await fs.writeFile(path.join(dir, "mango.ts"), "")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe(`apple.ts
+mango.ts
+zebra.ts`)
+})
+
+test("tree shows directories before files", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, "src"))
+ await fs.writeFile(path.join(dir, "src", "index.ts"), "")
+ await fs.writeFile(path.join(dir, "README.md"), "")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe(`src/
+\tindex.ts
+README.md`)
+})
+
+test("tree with nested directories", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, "src", "components"), { recursive: true })
+ await fs.writeFile(path.join(dir, "src", "components", "Button.tsx"), "")
+ await fs.writeFile(path.join(dir, "src", "components", "Input.tsx"), "")
+ await fs.writeFile(path.join(dir, "src", "index.ts"), "")
+ await fs.writeFile(path.join(dir, "package.json"), "{}")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe(`src/
+\tcomponents/
+\t\tButton.tsx
+\t\tInput.tsx
+\tindex.ts
+package.json`)
+})
+
+test("tree respects limit and shows truncation", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, "src"))
+ // Create more files than the limit
+ for (let i = 1; i <= 10; i++) {
+ await fs.writeFile(path.join(dir, "src", `file${i.toString().padStart(2, "0")}.ts`), "")
+ }
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 5 })
+ // With limit=5, we should see src/ and 4 files, then truncation
+ expect(result).toBe(`src/
+\tfile01.ts
+\tfile02.ts
+\tfile03.ts
+\tfile04.ts
+\t[6 truncated]`)
+})
+
+test("tree excludes .opencode directory", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, ".opencode"))
+ await fs.writeFile(path.join(dir, ".opencode", "config.json"), "{}")
+ await fs.writeFile(path.join(dir, "index.ts"), "")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe("index.ts")
+})
+
+test("tree handles multiple directories at same level", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, "api"))
+ await fs.mkdir(path.join(dir, "lib"))
+ await fs.mkdir(path.join(dir, "src"))
+ await fs.writeFile(path.join(dir, "api", "routes.ts"), "")
+ await fs.writeFile(path.join(dir, "lib", "utils.ts"), "")
+ await fs.writeFile(path.join(dir, "src", "index.ts"), "")
+ },
+ })
+ const result = await Ripgrep.tree({ cwd: tmp.path, limit: 50 })
+ expect(result).toBe(`api/
+\troutes.ts
+lib/
+\tutils.ts
+src/
+\tindex.ts`)
+})
diff --git a/packages/opencode/test/permission/editor.test.ts b/packages/opencode/test/permission/editor.test.ts
new file mode 100644
index 00000000000..0a6e6ebf646
--- /dev/null
+++ b/packages/opencode/test/permission/editor.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, test } from "bun:test"
+import { PermissionEditor } from "../../src/permission/editor"
+import type { Permission } from "../../src/permission"
+
+function makePermission(type: string, metadata: Record): Permission.Info {
+ return {
+ id: "test-permission",
+ type,
+ sessionID: "test-session",
+ messageID: "test-message",
+ title: "Test Permission",
+ metadata,
+ time: { created: Date.now() },
+ }
+}
+
+describe("PermissionEditor.canEdit", () => {
+ test("returns true for valid single-file edit permission", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/file.ts",
+ originalContent: "original",
+ suggestedContent: "suggested",
+ })
+ expect(PermissionEditor.canEdit(permission)).toBe(true)
+ })
+
+ test("returns false when missing filePath", () => {
+ const permission = makePermission("edit", {
+ originalContent: "original",
+ suggestedContent: "suggested",
+ })
+ expect(PermissionEditor.canEdit(permission)).toBe(false)
+ })
+
+ test("returns false when missing originalContent", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/file.ts",
+ suggestedContent: "suggested",
+ })
+ expect(PermissionEditor.canEdit(permission)).toBe(false)
+ })
+
+ test("returns false when missing suggestedContent", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/file.ts",
+ originalContent: "original",
+ })
+ expect(PermissionEditor.canEdit(permission)).toBe(false)
+ })
+
+ test("returns false for non-edit permission types", () => {
+ const permission = makePermission("bash", {
+ filePath: "/test/file.ts",
+ originalContent: "original",
+ suggestedContent: "suggested",
+ })
+ expect(PermissionEditor.canEdit(permission)).toBe(false)
+ })
+})
+
+describe("PermissionEditor.isEditable", () => {
+ test("returns true for single-file editable permission", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/file.ts",
+ originalContent: "original",
+ suggestedContent: "suggested",
+ })
+ expect(PermissionEditor.isEditable(permission)).toBe(true)
+ })
+
+ test("returns false for non-editable permission", () => {
+ const permission = makePermission("bash", { command: "ls" })
+ expect(PermissionEditor.isEditable(permission)).toBe(false)
+ })
+})
+
+describe("PermissionEditor.getStartLine", () => {
+ test("returns 1 when first line differs", () => {
+ const original = "line1\nline2\nline3"
+ const suggested = "changed\nline2\nline3"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(1)
+ })
+
+ test("returns correct line when middle line differs", () => {
+ const original = "line1\nline2\nline3"
+ const suggested = "line1\nchanged\nline3"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(2)
+ })
+
+ test("returns correct line when last line differs", () => {
+ const original = "line1\nline2\nline3"
+ const suggested = "line1\nline2\nchanged"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(3)
+ })
+
+ test("returns 1 for empty original", () => {
+ const original = ""
+ const suggested = "new content"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(1)
+ })
+
+ test("returns 1 when all lines are the same (edge case)", () => {
+ const original = "line1\nline2"
+ const suggested = "line1\nline2"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(1)
+ })
+
+ test("handles addition at end", () => {
+ const original = "line1\nline2"
+ const suggested = "line1\nline2\nline3"
+ expect(PermissionEditor.getStartLine(original, suggested)).toBe(3)
+ })
+})
+
+describe("PermissionEditor.hasChanges", () => {
+ test("returns false for identical content", () => {
+ expect(PermissionEditor.hasChanges("hello", "hello")).toBe(false)
+ })
+
+ test("returns true for different content", () => {
+ expect(PermissionEditor.hasChanges("hello", "world")).toBe(true)
+ })
+
+ test("normalizes CRLF to LF before comparison", () => {
+ expect(PermissionEditor.hasChanges("hello\r\nworld", "hello\nworld")).toBe(false)
+ })
+
+ test("returns true for whitespace differences (not line endings)", () => {
+ expect(PermissionEditor.hasChanges("hello world", "hello world")).toBe(true)
+ })
+})
+
+describe("PermissionEditor.computeDiff", () => {
+ test("produces valid unified diff", () => {
+ const diff = PermissionEditor.computeDiff("/test.ts", "old", "new")
+ expect(diff).toContain("-old")
+ expect(diff).toContain("+new")
+ })
+
+ test("handles empty original (new file)", () => {
+ const diff = PermissionEditor.computeDiff("/test.ts", "", "new content")
+ expect(diff).toContain("+new content")
+ })
+
+ test("handles empty suggested (file deletion)", () => {
+ const diff = PermissionEditor.computeDiff("/test.ts", "old content", "")
+ expect(diff).toContain("-old content")
+ })
+})
+
+describe("PermissionEditor.getExtension", () => {
+ test("returns file extension for single-file permission", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/file.tsx",
+ originalContent: "",
+ suggestedContent: "",
+ })
+ expect(PermissionEditor.getExtension(permission)).toBe(".tsx")
+ })
+
+ test("returns .txt for files without extension", () => {
+ const permission = makePermission("edit", {
+ filePath: "/test/Makefile",
+ originalContent: "",
+ suggestedContent: "",
+ })
+ expect(PermissionEditor.getExtension(permission)).toBe(".txt")
+ })
+})
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index c6c6924f01f..f6d2df9dd5b 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -1807,3 +1807,321 @@ test("custom model inherits api.url from models.dev provider", async () => {
},
})
})
+
+test("model variants are generated for reasoning models", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ // Claude sonnet 4 has reasoning capability
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.capabilities.reasoning).toBe(true)
+ expect(model.variants).toBeDefined()
+ expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
+ },
+ })
+})
+
+test("model variants can be disabled via config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ anthropic: {
+ models: {
+ "claude-sonnet-4-20250514": {
+ variants: {
+ high: { disabled: true },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.variants).toBeDefined()
+ expect(model.variants!["high"]).toBeUndefined()
+ // max variant should still exist
+ expect(model.variants!["max"]).toBeDefined()
+ },
+ })
+})
+
+test("model variants can be customized via config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ anthropic: {
+ models: {
+ "claude-sonnet-4-20250514": {
+ variants: {
+ high: {
+ thinking: {
+ type: "enabled",
+ budgetTokens: 20000,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.variants!["high"]).toBeDefined()
+ expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
+ },
+ })
+})
+
+test("disabled key is stripped from variant config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ anthropic: {
+ models: {
+ "claude-sonnet-4-20250514": {
+ variants: {
+ max: {
+ disabled: false,
+ customField: "test",
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.variants!["max"]).toBeDefined()
+ expect(model.variants!["max"].disabled).toBeUndefined()
+ expect(model.variants!["max"].customField).toBe("test")
+ },
+ })
+})
+
+test("all variants can be disabled via config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ anthropic: {
+ models: {
+ "claude-sonnet-4-20250514": {
+ variants: {
+ high: { disabled: true },
+ max: { disabled: true },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.variants).toBeDefined()
+ expect(Object.keys(model.variants!).length).toBe(0)
+ },
+ })
+})
+
+test("variant config merges with generated variants", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ anthropic: {
+ models: {
+ "claude-sonnet-4-20250514": {
+ variants: {
+ high: {
+ extraOption: "custom-value",
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("ANTHROPIC_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
+ expect(model.variants!["high"]).toBeDefined()
+ // Should have both the generated thinking config and the custom option
+ expect(model.variants!["high"].thinking).toBeDefined()
+ expect(model.variants!["high"].extraOption).toBe("custom-value")
+ },
+ })
+})
+
+test("variants filtered in second pass for database models", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ openai: {
+ models: {
+ "gpt-5": {
+ variants: {
+ high: { disabled: true },
+ },
+ },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("OPENAI_API_KEY", "test-api-key")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["openai"].models["gpt-5"]
+ expect(model.variants).toBeDefined()
+ expect(model.variants!["high"]).toBeUndefined()
+ // Other variants should still exist
+ expect(model.variants!["medium"]).toBeDefined()
+ },
+ })
+})
+
+test("custom model with variants enabled and disabled", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "custom-reasoning": {
+ name: "Custom Reasoning Provider",
+ npm: "@ai-sdk/openai-compatible",
+ env: [],
+ models: {
+ "reasoning-model": {
+ name: "Reasoning Model",
+ tool_call: true,
+ reasoning: true,
+ limit: { context: 128000, output: 16000 },
+ variants: {
+ low: { reasoningEffort: "low" },
+ medium: { reasoningEffort: "medium" },
+ high: { reasoningEffort: "high", disabled: true },
+ custom: { reasoningEffort: "custom", budgetTokens: 5000 },
+ },
+ },
+ },
+ options: { apiKey: "test-key" },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const providers = await Provider.list()
+ const model = providers["custom-reasoning"].models["reasoning-model"]
+ expect(model.variants).toBeDefined()
+ // Enabled variants should exist
+ expect(model.variants!["low"]).toBeDefined()
+ expect(model.variants!["low"].reasoningEffort).toBe("low")
+ expect(model.variants!["medium"]).toBeDefined()
+ expect(model.variants!["medium"].reasoningEffort).toBe("medium")
+ expect(model.variants!["custom"]).toBeDefined()
+ expect(model.variants!["custom"].reasoningEffort).toBe("custom")
+ expect(model.variants!["custom"].budgetTokens).toBe(5000)
+ // Disabled variant should not exist
+ expect(model.variants!["high"]).toBeUndefined()
+ // disabled key should be stripped from all variants
+ expect(model.variants!["low"].disabled).toBeUndefined()
+ expect(model.variants!["medium"].disabled).toBeUndefined()
+ expect(model.variants!["custom"].disabled).toBeUndefined()
+ },
+ })
+})
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 78bd296c99c..59041b09fa6 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -409,3 +409,572 @@ describe("ProviderTransform.message - empty image handling", () => {
})
})
})
+
+describe("ProviderTransform.variants", () => {
+ const createMockModel = (overrides: Partial = {}): any => ({
+ id: "test/test-model",
+ providerID: "test",
+ api: {
+ id: "test-model",
+ url: "https://api.test.com",
+ npm: "@ai-sdk/openai",
+ },
+ name: "Test Model",
+ capabilities: {
+ temperature: true,
+ reasoning: true,
+ attachment: true,
+ toolcall: true,
+ input: { text: true, audio: false, image: true, 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: {},
+ release_date: "2024-01-01",
+ ...overrides,
+ })
+
+ test("returns empty object when model has no reasoning capabilities", () => {
+ const model = createMockModel({
+ capabilities: { reasoning: false },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("deepseek returns empty object", () => {
+ const model = createMockModel({
+ id: "deepseek/deepseek-chat",
+ providerID: "deepseek",
+ api: {
+ id: "deepseek-chat",
+ url: "https://api.deepseek.com",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("minimax returns empty object", () => {
+ const model = createMockModel({
+ id: "minimax/minimax-model",
+ providerID: "minimax",
+ api: {
+ id: "minimax-model",
+ url: "https://api.minimax.com",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("glm returns empty object", () => {
+ const model = createMockModel({
+ id: "glm/glm-4",
+ providerID: "glm",
+ api: {
+ id: "glm-4",
+ url: "https://api.glm.com",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("mistral returns empty object", () => {
+ const model = createMockModel({
+ id: "mistral/mistral-large",
+ providerID: "mistral",
+ api: {
+ id: "mistral-large-latest",
+ url: "https://api.mistral.com",
+ npm: "@ai-sdk/mistral",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ describe("@openrouter/ai-sdk-provider", () => {
+ test("returns empty object for non-qualifying models", () => {
+ const model = createMockModel({
+ id: "openrouter/test-model",
+ providerID: "openrouter",
+ api: {
+ id: "test-model",
+ url: "https://openrouter.ai",
+ npm: "@openrouter/ai-sdk-provider",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("gpt models return OPENAI_EFFORTS with reasoning", () => {
+ const model = createMockModel({
+ id: "openrouter/gpt-4",
+ providerID: "openrouter",
+ api: {
+ id: "gpt-4",
+ url: "https://openrouter.ai",
+ npm: "@openrouter/ai-sdk-provider",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
+ expect(result.low).toEqual({ reasoning: { effort: "low" } })
+ expect(result.high).toEqual({ reasoning: { effort: "high" } })
+ })
+
+ test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => {
+ const model = createMockModel({
+ id: "openrouter/gemini-3-5-pro",
+ providerID: "openrouter",
+ api: {
+ id: "gemini-3-5-pro",
+ url: "https://openrouter.ai",
+ npm: "@openrouter/ai-sdk-provider",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
+ })
+
+ test("grok-4 returns OPENAI_EFFORTS with reasoning", () => {
+ const model = createMockModel({
+ id: "openrouter/grok-4",
+ providerID: "openrouter",
+ api: {
+ id: "grok-4",
+ url: "https://openrouter.ai",
+ npm: "@openrouter/ai-sdk-provider",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
+ })
+ })
+
+ describe("@ai-sdk/gateway", () => {
+ test("returns OPENAI_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "gateway/gateway-model",
+ providerID: "gateway",
+ api: {
+ id: "gateway-model",
+ url: "https://gateway.ai",
+ npm: "@ai-sdk/gateway",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/cerebras", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "cerebras/llama-4",
+ providerID: "cerebras",
+ api: {
+ id: "llama-4-sc",
+ url: "https://api.cerebras.ai",
+ npm: "@ai-sdk/cerebras",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/togetherai", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "togetherai/llama-4",
+ providerID: "togetherai",
+ api: {
+ id: "llama-4-sc",
+ url: "https://api.togetherai.com",
+ npm: "@ai-sdk/togetherai",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/xai", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "xai/grok-3",
+ providerID: "xai",
+ api: {
+ id: "grok-3",
+ url: "https://api.x.ai",
+ npm: "@ai-sdk/xai",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/deepinfra", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "deepinfra/llama-4",
+ providerID: "deepinfra",
+ api: {
+ id: "llama-4-sc",
+ url: "https://api.deepinfra.com",
+ npm: "@ai-sdk/deepinfra",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/openai-compatible", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
+ const model = createMockModel({
+ id: "custom-provider/custom-model",
+ providerID: "custom-provider",
+ api: {
+ id: "custom-model",
+ url: "https://api.custom.com",
+ npm: "@ai-sdk/openai-compatible",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({ reasoningEffort: "low" })
+ expect(result.high).toEqual({ reasoningEffort: "high" })
+ })
+ })
+
+ describe("@ai-sdk/azure", () => {
+ test("o1-mini returns empty object", () => {
+ const model = createMockModel({
+ id: "o1-mini",
+ providerID: "azure",
+ api: {
+ id: "o1-mini",
+ url: "https://azure.com",
+ npm: "@ai-sdk/azure",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("standard azure models return custom efforts with reasoningSummary", () => {
+ const model = createMockModel({
+ id: "azure/gpt-4o",
+ providerID: "azure",
+ api: {
+ id: "gpt-4o",
+ url: "https://azure.com",
+ npm: "@ai-sdk/azure",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({
+ reasoningEffort: "low",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ })
+ })
+
+ test("gpt-5 adds minimal effort", () => {
+ const model = createMockModel({
+ id: "azure/gpt-5",
+ providerID: "azure",
+ api: {
+ id: "gpt-5",
+ url: "https://azure.com",
+ npm: "@ai-sdk/azure",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
+ })
+ })
+
+ describe("@ai-sdk/openai", () => {
+ test("gpt-5-pro returns empty object", () => {
+ const model = createMockModel({
+ id: "gpt-5-pro",
+ providerID: "openai",
+ api: {
+ id: "gpt-5-pro",
+ url: "https://api.openai.com",
+ npm: "@ai-sdk/openai",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("standard openai models return custom efforts with reasoningSummary", () => {
+ const model = createMockModel({
+ id: "openai/gpt-4o",
+ providerID: "openai",
+ api: {
+ id: "gpt-4o",
+ url: "https://api.openai.com",
+ npm: "@ai-sdk/openai",
+ },
+ release_date: "2024-06-01",
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
+ expect(result.low).toEqual({
+ reasoningEffort: "low",
+ reasoningSummary: "auto",
+ include: ["reasoning.encrypted_content"],
+ })
+ })
+
+ test("models after 2025-11-13 include 'none' effort", () => {
+ const model = createMockModel({
+ id: "openai/gpt-4.5",
+ providerID: "openai",
+ api: {
+ id: "gpt-4.5",
+ url: "https://api.openai.com",
+ npm: "@ai-sdk/openai",
+ },
+ release_date: "2025-11-14",
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high"])
+ })
+
+ test("models after 2025-12-04 include 'xhigh' effort", () => {
+ const model = createMockModel({
+ id: "openai/gpt-5-chat",
+ providerID: "openai",
+ api: {
+ id: "gpt-5-chat",
+ url: "https://api.openai.com",
+ npm: "@ai-sdk/openai",
+ },
+ release_date: "2025-12-05",
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
+ })
+ })
+
+ describe("@ai-sdk/anthropic", () => {
+ test("returns high and max with thinking config", () => {
+ const model = createMockModel({
+ id: "anthropic/claude-4",
+ providerID: "anthropic",
+ api: {
+ id: "claude-4",
+ url: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["high", "max"])
+ expect(result.high).toEqual({
+ thinking: {
+ type: "enabled",
+ budgetTokens: 16000,
+ },
+ })
+ expect(result.max).toEqual({
+ thinking: {
+ type: "enabled",
+ budgetTokens: 31999,
+ },
+ })
+ })
+ })
+
+ describe("@ai-sdk/amazon-bedrock", () => {
+ test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
+ const model = createMockModel({
+ id: "bedrock/llama-4",
+ providerID: "bedrock",
+ api: {
+ id: "llama-4-sc",
+ url: "https://bedrock.amazonaws.com",
+ npm: "@ai-sdk/amazon-bedrock",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "medium", "high"])
+ expect(result.low).toEqual({
+ reasoningConfig: {
+ type: "enabled",
+ maxReasoningEffort: "low",
+ },
+ })
+ })
+ })
+
+ describe("@ai-sdk/google", () => {
+ test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
+ const model = createMockModel({
+ id: "google/gemini-2.5-pro",
+ providerID: "google",
+ api: {
+ id: "gemini-2.5-pro",
+ url: "https://generativelanguage.googleapis.com",
+ npm: "@ai-sdk/google",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["high", "max"])
+ expect(result.high).toEqual({
+ thinkingConfig: {
+ includeThoughts: true,
+ thinkingBudget: 16000,
+ },
+ })
+ expect(result.max).toEqual({
+ thinkingConfig: {
+ includeThoughts: true,
+ thinkingBudget: 24576,
+ },
+ })
+ })
+
+ test("other gemini models return low and high with thinkingLevel", () => {
+ const model = createMockModel({
+ id: "google/gemini-2.0-pro",
+ providerID: "google",
+ api: {
+ id: "gemini-2.0-pro",
+ url: "https://generativelanguage.googleapis.com",
+ npm: "@ai-sdk/google",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "high"])
+ expect(result.low).toEqual({
+ includeThoughts: true,
+ thinkingLevel: "low",
+ })
+ expect(result.high).toEqual({
+ includeThoughts: true,
+ thinkingLevel: "high",
+ })
+ })
+ })
+
+ describe("@ai-sdk/google-vertex", () => {
+ test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
+ const model = createMockModel({
+ id: "google-vertex/gemini-2.5-pro",
+ providerID: "google-vertex",
+ api: {
+ id: "gemini-2.5-pro",
+ url: "https://vertexai.googleapis.com",
+ npm: "@ai-sdk/google-vertex",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["high", "max"])
+ })
+
+ test("other vertex models return low and high with thinkingLevel", () => {
+ const model = createMockModel({
+ id: "google-vertex/gemini-2.0-pro",
+ providerID: "google-vertex",
+ api: {
+ id: "gemini-2.0-pro",
+ url: "https://vertexai.googleapis.com",
+ npm: "@ai-sdk/google-vertex",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["low", "high"])
+ })
+ })
+
+ describe("@ai-sdk/cohere", () => {
+ test("returns empty object", () => {
+ const model = createMockModel({
+ id: "cohere/command-r",
+ providerID: "cohere",
+ api: {
+ id: "command-r",
+ url: "https://api.cohere.com",
+ npm: "@ai-sdk/cohere",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+ })
+
+ describe("@ai-sdk/groq", () => {
+ test("returns none and WIDELY_SUPPORTED_EFFORTS with thinkingLevel", () => {
+ const model = createMockModel({
+ id: "groq/llama-4",
+ providerID: "groq",
+ api: {
+ id: "llama-4-sc",
+ url: "https://api.groq.com",
+ npm: "@ai-sdk/groq",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"])
+ expect(result.none).toEqual({
+ includeThoughts: true,
+ thinkingLevel: "none",
+ })
+ expect(result.low).toEqual({
+ includeThoughts: true,
+ thinkingLevel: "low",
+ })
+ })
+ })
+
+ describe("@ai-sdk/perplexity", () => {
+ test("returns empty object", () => {
+ const model = createMockModel({
+ id: "perplexity/sonar-plus",
+ providerID: "perplexity",
+ api: {
+ id: "sonar-plus",
+ url: "https://api.perplexity.ai",
+ npm: "@ai-sdk/perplexity",
+ },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+ })
+})
diff --git a/packages/opencode/test/server/cors.test.ts b/packages/opencode/test/server/cors.test.ts
new file mode 100644
index 00000000000..4b96c260af8
--- /dev/null
+++ b/packages/opencode/test/server/cors.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import { isOriginAllowed } from "../../src/server/cors"
+
+describe("server.cors", () => {
+ describe("isOriginAllowed", () => {
+ test("should return undefined for undefined input", () => {
+ expect(isOriginAllowed(undefined)).toBeUndefined()
+ })
+
+ test("should return undefined for empty string", () => {
+ expect(isOriginAllowed("")).toBeUndefined()
+ })
+
+ test("should allow localhost with any port", () => {
+ expect(isOriginAllowed("http://localhost:3000")).toBe("http://localhost:3000")
+ expect(isOriginAllowed("http://localhost:4096")).toBe("http://localhost:4096")
+ expect(isOriginAllowed("http://localhost:8080")).toBe("http://localhost:8080")
+ })
+
+ test("should allow 127.0.0.1 with any port", () => {
+ expect(isOriginAllowed("http://127.0.0.1:3000")).toBe("http://127.0.0.1:3000")
+ expect(isOriginAllowed("http://127.0.0.1:4096")).toBe("http://127.0.0.1:4096")
+ })
+
+ test("should allow Tauri origins", () => {
+ expect(isOriginAllowed("tauri://localhost")).toBe("tauri://localhost")
+ expect(isOriginAllowed("http://tauri.localhost")).toBe("http://tauri.localhost")
+ })
+
+ test("should allow *.opencode.ai origins (https only)", () => {
+ expect(isOriginAllowed("https://opencode.ai")).toBe("https://opencode.ai")
+ expect(isOriginAllowed("https://app.opencode.ai")).toBe("https://app.opencode.ai")
+ expect(isOriginAllowed("https://foo.opencode.ai")).toBe("https://foo.opencode.ai")
+ expect(isOriginAllowed("https://dev.app.opencode.ai")).toBe("https://dev.app.opencode.ai")
+ })
+
+ test("should allow *.shuv.ai origins (https only)", () => {
+ expect(isOriginAllowed("https://shuv.ai")).toBe("https://shuv.ai")
+ expect(isOriginAllowed("https://app.shuv.ai")).toBe("https://app.shuv.ai")
+ expect(isOriginAllowed("https://foo.shuv.ai")).toBe("https://foo.shuv.ai")
+ expect(isOriginAllowed("https://dev.app.shuv.ai")).toBe("https://dev.app.shuv.ai")
+ })
+
+ test("should deny http:// for opencode.ai and shuv.ai domains", () => {
+ expect(isOriginAllowed("http://opencode.ai")).toBeUndefined()
+ expect(isOriginAllowed("http://app.opencode.ai")).toBeUndefined()
+ expect(isOriginAllowed("http://shuv.ai")).toBeUndefined()
+ expect(isOriginAllowed("http://app.shuv.ai")).toBeUndefined()
+ })
+
+ test("should deny other domains", () => {
+ expect(isOriginAllowed("https://evil.com")).toBeUndefined()
+ expect(isOriginAllowed("https://example.com")).toBeUndefined()
+ expect(isOriginAllowed("https://fakeopencode.ai")).toBeUndefined()
+ expect(isOriginAllowed("https://fakeshuv.ai")).toBeUndefined()
+ })
+
+ test("should deny https localhost (not typical)", () => {
+ expect(isOriginAllowed("https://localhost:3000")).toBeUndefined()
+ })
+ })
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 764ad40b9c9..b0b41dbca17 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 26368f14611..5a4c93b29c8 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -165,6 +165,10 @@ export interface Hooks {
output: { temperature: number; topP: number; topK: number; options: Record },
) => Promise
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise
+ "command.execute.before"?: (
+ input: { command: string; sessionID: string; arguments: string },
+ output: { parts: Part[] },
+ ) => Promise
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
output: { args: any },
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index ebea43eb466..0a1bca0f7e7 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 0d098040b11..78b75fe1b2b 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -1666,7 +1666,10 @@ export class Permission extends HeyApiClient {
sessionID: string
permissionID: string
directory?: string
- response?: "once" | "always" | "reject"
+ response?: "once" | "always" | "reject" | "modify"
+ modifyData?: {
+ content: string
+ }
},
options?: Options,
) {
@@ -1679,6 +1682,7 @@ export class Permission extends HeyApiClient {
{ in: "path", key: "permissionID" },
{ in: "query", key: "directory" },
{ in: "body", key: "response" },
+ { in: "body", key: "modifyData" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 8a854c0a51f..8f01172ee5c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -422,6 +422,15 @@ export type Part =
description: string
agent: string
command?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ parentAgent?: string
+ parentModel?: {
+ providerID: string
+ modelID: string
+ }
}
| ReasoningPart
| FilePart
@@ -1003,6 +1012,10 @@ export type KeybindsConfig = {
* Interrupt current session
*/
session_interrupt?: string
+ /**
+ * Edit suggested changes before applying
+ */
+ permission_edit?: string
/**
* Compact the session
*/
@@ -1310,6 +1323,10 @@ export type ServerConfig = {
* Enable mDNS service discovery
*/
mdns?: boolean
+ /**
+ * Additional domains to allow for CORS
+ */
+ cors?: Array
}
export type AgentConfig = {
@@ -1442,6 +1459,18 @@ export type ProviderConfig = {
provider?: {
npm: string
}
+ /**
+ * Variant-specific configuration
+ */
+ variants?: {
+ [key: string]: {
+ /**
+ * Disable this variant for the model
+ */
+ disabled?: boolean
+ [key: string]: unknown | boolean | undefined
+ }
+ }
}
}
whitelist?: Array
@@ -1796,6 +1825,10 @@ export type Config = {
* Continue the agent loop when a tool call is denied
*/
continue_loop_on_deny?: boolean
+ /**
+ * Timeout in milliseconds for model context protocol (MCP) requests
+ */
+ mcp_timeout?: number
}
}
@@ -1863,6 +1896,15 @@ export type SubtaskPartInput = {
description: string
agent: string
command?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ parentAgent?: string
+ parentModel?: {
+ providerID: string
+ modelID: string
+ }
}
export type Command = {
@@ -1870,11 +1912,13 @@ export type Command = {
description?: string
agent?: string
model?: string
+ mcp?: boolean
template: string
- type: "template" | "plugin"
+ type?: "template" | "plugin"
subtask?: boolean
sessionOnly?: boolean
aliases?: Array
+ hints: Array
}
export type Variant = {
@@ -1946,7 +1990,9 @@ export type Model = {
}
release_date: string
variants?: {
- [key: string]: Variant
+ [key: string]: {
+ [key: string]: unknown
+ }
}
}
@@ -3584,7 +3630,10 @@ export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnre
export type PermissionRespondData = {
body?: {
- response: "once" | "always" | "reject"
+ response: "once" | "always" | "reject" | "modify"
+ modifyData?: {
+ content: string
+ }
}
path: {
sessionID: string
@@ -3825,6 +3874,11 @@ export type ProviderListResponses = {
provider?: {
npm: string
}
+ variants?: {
+ [key: string]: {
+ [key: string]: unknown
+ }
+ }
}
}
}>
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index db9e5411899..8e79202dd39 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -2872,7 +2872,16 @@
"properties": {
"response": {
"type": "string",
- "enum": ["once", "always", "reject"]
+ "enum": ["once", "always", "reject", "modify"]
+ },
+ "modifyData": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string"
+ }
+ },
+ "required": ["content"]
}
},
"required": ["response"]
@@ -3209,6 +3218,19 @@
}
},
"required": ["npm"]
+ },
+ "variants": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
+ }
}
},
"required": [
@@ -7405,6 +7427,11 @@
"default": "escape",
"type": "string"
},
+ "permission_edit": {
+ "description": "Edit suggested changes before applying",
+ "default": "e",
+ "type": "string"
+ },
"session_compact": {
"description": "Compact the session",
"default": "c",
@@ -7780,6 +7807,13 @@
"mdns": {
"description": "Enable mDNS service discovery",
"type": "boolean"
+ },
+ "cors": {
+ "description": "Additional domains to allow for CORS",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
"additionalProperties": false
@@ -8061,6 +8095,23 @@
}
},
"required": ["npm"]
+ },
+ "variants": {
+ "description": "Variant-specific configuration",
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "disabled": {
+ "description": "Disable this variant for the model",
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": {}
+ }
}
}
}
@@ -8737,6 +8788,12 @@
"continue_loop_on_deny": {
"description": "Continue the agent loop when a tool call is denied",
"type": "boolean"
+ },
+ "mcp_timeout": {
+ "description": "Timeout in milliseconds for model context protocol (MCP) requests",
+ "type": "integer",
+ "exclusiveMinimum": 0,
+ "maximum": 9007199254740991
}
}
}
@@ -8939,24 +8996,30 @@
"model": {
"type": "string"
},
+ "mcp": {
+ "type": "boolean"
+ },
"template": {
- "type": "string"
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string"
+ }
+ ]
},
"subtask": {
"type": "boolean"
+ },
+ "hints": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
- "required": ["name", "template"]
- },
- "Variant": {
- "type": "object",
- "properties": {
- "disabled": {
- "type": "boolean"
- }
- },
- "required": ["disabled"],
- "additionalProperties": {}
+ "required": ["name", "template", "hints"]
},
"Model": {
"type": "object",
@@ -9154,7 +9217,11 @@
"type": "string"
},
"additionalProperties": {
- "$ref": "#/components/schemas/Variant"
+ "type": "object",
+ "propertyNames": {
+ "type": "string"
+ },
+ "additionalProperties": {}
}
}
},
diff --git a/packages/slack/package.json b/packages/slack/package.json
index 374335d0166..f4919263c0d 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index cff26b92013..ad0f2c1433b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.0.218",
+ "version": "1.0.220",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index 77696faed29..fda08260fca 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,7 +1,7 @@
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js"
import { createDefaultOptions, styleVariables } from "../pierre"
-import { workerPool } from "../pierre/worker"
+import { getWorkerPool } from "../pierre/worker"
export type CodeProps = FileOptions & {
file: FileContents
@@ -21,7 +21,7 @@ export function Code(props: CodeProps) {
...createDefaultOptions("unified"),
...others,
},
- workerPool,
+ getWorkerPool("unified"),
),
)
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
index e367a4fbe18..56a12c100f1 100644
--- a/packages/ui/src/components/diff-ssr.tsx
+++ b/packages/ui/src/components/diff-ssr.tsx
@@ -13,7 +13,7 @@ export function Diff(props: SSRDiffProps) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
- const workerPool = useWorkerPool()
+ const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff | undefined
const cleanupFunctions: Array<() => void> = []
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 75dde044049..ff70cece956 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -1,7 +1,7 @@
import { FileDiff } from "@pierre/diffs"
import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
-import { workerPool } from "../pierre/worker"
+import { getWorkerPool } from "../pierre/worker"
// interface ThreadMetadata {
// threadId: string
@@ -20,26 +20,23 @@ export function Diff(props: DiffProps) {
...createDefaultOptions(props.diffStyle),
...others,
},
- workerPool,
+ getWorkerPool(props.diffStyle),
),
)
- const cleanupFunctions: Array<() => void> = []
-
createEffect(() => {
+ const diff = fileDiff()
container.innerHTML = ""
- fileDiff().render({
+ diff.render({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations,
containerWrapper: container,
})
- })
- onCleanup(() => {
- // Clean up FileDiff event handlers and dispose SolidJS components
- fileDiff()?.cleanUp()
- cleanupFunctions.forEach((dispose) => dispose())
+ onCleanup(() => {
+ diff.cleanUp()
+ })
})
return
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index c56f477881c..db9f71af634 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -59,6 +59,7 @@ const icons = {
share: ` `,
download: ` `,
menu: ` `,
+ server: ` `,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index c34a76e6d89..8d618b9d9cc 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -78,10 +78,9 @@
[data-slot="user-message-text"] {
white-space: pre-wrap;
overflow: hidden;
- background: var(--surface-inset-base);
- padding: 6px 12px;
+ background: var(--surface-base);
+ padding: 8px 12px;
border-radius: 4px;
- border: 0.5px solid var(--border-weak-base);
}
.text-text-strong {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index cf4daebbfc3..becb9fefd92 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -874,7 +874,6 @@ ToolRegistry.register({
return (
@@ -926,7 +925,6 @@ ToolRegistry.register({
return (
diff --git a/packages/ui/src/components/radio-group.css b/packages/ui/src/components/radio-group.css
index 38773b819ad..3d672bb300d 100644
--- a/packages/ui/src/components/radio-group.css
+++ b/packages/ui/src/components/radio-group.css
@@ -7,7 +7,7 @@
all: unset;
background-color: var(--surface-base);
border-radius: var(--radius-md);
- box-shadow: inset 0 0 0 1px var(--border-weak-base);
+ box-shadow: var(--shadow-xs-border);
margin: 0;
padding: 0;
position: relative;
@@ -23,10 +23,7 @@
[data-slot="radio-group-indicator"] {
background: var(--button-secondary-base);
border-radius: var(--radius-md);
- box-shadow:
- var(--shadow-xs),
- inset 0 0 0 var(--indicator-focus-width, 0px) var(--border-selected),
- inset 0 0 0 1px var(--border-base);
+ box-shadow: var(--shadow-xs-border);
content: "";
opacity: var(--indicator-opacity, 1);
position: absolute;
@@ -115,7 +112,7 @@
/* Focus state */
[data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
[data-slot="radio-group-indicator"] {
- --indicator-focus-width: 2px;
+ box-shadow: var(--shadow-xs-border-focus);
}
/* Hide indicator when nothing is checked */
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 9162b52164e..9e6c633f445 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -1,5 +1,6 @@
import { Accordion } from "./accordion"
import { Button } from "./button"
+import { RadioGroup } from "./radio-group"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
@@ -13,8 +14,12 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Dynamic } from "solid-js/web"
import { checksum } from "@opencode-ai/util/encode"
+export type SessionReviewDiffStyle = "unified" | "split"
+
export interface SessionReviewProps {
split?: boolean
+ diffStyle?: SessionReviewDiffStyle
+ onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
class?: string
classList?: Record
classes?: { root?: string; header?: string; container?: string }
@@ -28,6 +33,8 @@ export const SessionReview = (props: SessionReviewProps) => {
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
})
+ const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
+
const handleChange = (open: string[]) => {
setStore("open", open)
}
@@ -60,6 +67,15 @@ export const SessionReview = (props: SessionReviewProps) => {
>
Session changes
+
+ style}
+ label={(style) => (style === "unified" ? "Unified" : "Split")}
+ onSelect={(style) => style && props.onDiffStyleChange?.(style)}
+ />
+
0}>Collapse all
@@ -102,7 +118,7 @@ export const SessionReview = (props: SessionReviewProps) => {
{
- const parts = msgParts()
+ let parts = msgParts()
+
+ if (props.hideReasoning) {
+ parts = parts.filter((part) => part?.type !== "reasoning")
+ }
+
if (!props.hideResponsePart) return parts
const responsePartId = props.responsePartId
@@ -556,6 +562,7 @@ export function SessionTurn(
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
+ hideReasoning={!working()}
/>
)}
diff --git a/packages/ui/src/context/worker-pool.tsx b/packages/ui/src/context/worker-pool.tsx
index fc2eecc0343..5f788f7866a 100644
--- a/packages/ui/src/context/worker-pool.tsx
+++ b/packages/ui/src/context/worker-pool.tsx
@@ -1,10 +1,20 @@
import type { WorkerPoolManager } from "@pierre/diffs/worker"
import { createSimpleContext } from "./helper"
-const ctx = createSimpleContext({
+export type WorkerPools = {
+ unified: WorkerPoolManager | undefined
+ split: WorkerPoolManager | undefined
+}
+
+const ctx = createSimpleContext({
name: "WorkerPool",
- init: (props) => props.pool,
+ init: (props) => props.pools,
})
export const WorkerPoolProvider = ctx.provider
-export const useWorkerPool = ctx.use
+
+export function useWorkerPool(diffStyle: "unified" | "split" | undefined) {
+ const pools = ctx.use()
+ if (diffStyle === "split") return pools.split
+ return pools.unified
+}
diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts
index 2d264067426..0d117c3683f 100644
--- a/packages/ui/src/pierre/worker.ts
+++ b/packages/ui/src/pierre/worker.ts
@@ -1,16 +1,15 @@
-import { getOrCreateWorkerPoolSingleton, WorkerPoolManager } from "@pierre/diffs/worker"
+import { WorkerPoolManager } from "@pierre/diffs/worker"
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
+export type WorkerPoolStyle = "unified" | "split"
+
export function workerFactory(): Worker {
return new Worker(ShikiWorkerUrl, { type: "module" })
}
-export const workerPool: WorkerPoolManager | undefined = (() => {
- if (typeof window === "undefined") {
- return undefined
- }
- return getOrCreateWorkerPoolSingleton({
- poolOptions: {
+function createPool(lineDiffType: "none" | "word-alt") {
+ const pool = new WorkerPoolManager(
+ {
workerFactory,
// poolSize defaults to 8. More workers = more parallelism but
// also more memory. Too many can actually slow things down.
@@ -19,10 +18,34 @@ export const workerPool: WorkerPoolManager | undefined = (() => {
// boot up time for workers
poolSize: 2,
},
- highlighterOptions: {
+ {
theme: "OpenCode",
- // Optionally preload languages to avoid lazy-loading delays
- // langs: ["typescript", "javascript", "css", "html"],
+ lineDiffType,
},
- })
-})()
+ )
+
+ pool.initialize()
+ return pool
+}
+
+let unified: WorkerPoolManager | undefined
+let split: WorkerPoolManager | undefined
+
+export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
+ if (typeof window === "undefined") return
+
+ if (style === "split") {
+ if (!split) split = createPool("word-alt")
+ return split
+ }
+
+ if (!unified) unified = createPool("none")
+ return unified
+}
+
+export function getWorkerPools() {
+ return {
+ unified: getWorkerPool("unified"),
+ split: getWorkerPool("split"),
+ }
+}
diff --git a/packages/util/package.json b/packages/util/package.json
index 7df95a5ad81..14394c504d0 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
- "version": "1.0.218",
+ "version": "1.0.220",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/web/package.json b/packages/web/package.json
index 8e86155a9ba..88cdb0ccf89 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
- "version": "1.0.218",
+ "version": "1.0.220",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index 1553dc80ee9..329ce2ee7d7 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -362,11 +362,12 @@ This starts an HTTP server that provides API access to opencode functionality wi
#### Flags
-| Flag | Description |
-| ------------ | --------------------- |
-| `--port` | Port to listen on |
-| `--hostname` | Hostname to listen on |
-| `--mdns` | Enable mDNS discovery |
+| Flag | Description |
+| ------------ | ------------------------------------------ |
+| `--port` | Port to listen on |
+| `--hostname` | Hostname to listen on |
+| `--mdns` | Enable mDNS discovery |
+| `--cors` | Additional browser origin(s) to allow CORS |
---
@@ -407,11 +408,12 @@ opencode stats
#### Flags
-| Flag | Description |
-| ----------- | --------------------------------------------------------------- |
-| `--days` | Show stats for the last N days (all time) |
-| `--tools` | Number of tools to show (all) |
-| `--project` | Filter by project (all projects, empty string: current project) |
+| Flag | Description |
+| ----------- | --------------------------------------------------------------------------- |
+| `--days` | Show stats for the last N days (all time) |
+| `--tools` | Number of tools to show (all) |
+| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N |
+| `--project` | Filter by project (all projects, empty string: current project) |
---
@@ -456,11 +458,12 @@ This starts an HTTP server and opens a web browser to access OpenCode through a
#### Flags
-| Flag | Description |
-| ------------ | --------------------- |
-| `--port` | Port to listen on |
-| `--hostname` | Hostname to listen on |
-| `--mdns` | Enable mDNS discovery |
+| Flag | Description |
+| ------------ | ------------------------------------------ |
+| `--port` | Port to listen on |
+| `--hostname` | Hostname to listen on |
+| `--mdns` | Enable mDNS discovery |
+| `--cors` | Additional browser origin(s) to allow CORS |
---
diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx
index aa0a85320cf..24b822cc423 100644
--- a/packages/web/src/content/docs/config.mdx
+++ b/packages/web/src/content/docs/config.mdx
@@ -132,7 +132,8 @@ You can configure server settings for the `opencode serve` and `opencode web` co
"server": {
"port": 4096,
"hostname": "0.0.0.0",
- "mdns": true
+ "mdns": true,
+ "cors": ["http://localhost:5173"]
}
}
```
@@ -142,6 +143,7 @@ Available options:
- `port` - Port to listen on.
- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`.
- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
+- `cors` - Additional origins to allow for CORS when using the HTTP server from a browser-based client. Values must be full origins (scheme + host + optional port), eg `https://app.example.com`.
[Learn more about the server here](/docs/server).
diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx
index a7b708f7304..34cce22ab58 100644
--- a/packages/web/src/content/docs/ecosystem.mdx
+++ b/packages/web/src/content/docs/ecosystem.mdx
@@ -15,28 +15,29 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
## Plugins
-| Name | Description |
-| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
-| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
-| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
-| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
-| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
-| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
-| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
-| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
-| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
-| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
-| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
-| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
-| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
-| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
-| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
-| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
-| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
-| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
-| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
-| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
+| Name | Description |
+| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
+| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
+| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
+| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
+| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
+| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
+| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
+| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
+| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
+| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
+| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
+| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
+| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
+| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
+| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
+| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
+| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
+| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
+| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
+| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
+| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
---
diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx
index 137d3be1ee1..b49ec71c307 100644
--- a/packages/web/src/content/docs/formatters.mdx
+++ b/packages/web/src/content/docs/formatters.mdx
@@ -21,6 +21,7 @@ OpenCode comes with several built-in formatters for popular languages and framew
| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
| ktlint | .kt, .kts | `ktlint` command available |
| ruff | .py, .pyi | `ruff` command available with config |
+| rustfmt | .rs | `rustfmt` command available |
| uv | .py, .pyi | `uv` command available |
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |
diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx
index 04d03d0d849..79464642fd4 100644
--- a/packages/web/src/content/docs/keybinds.mdx
+++ b/packages/web/src/content/docs/keybinds.mdx
@@ -43,6 +43,7 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"model_list": "m",
"model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2",
+ "variant_cycle": "ctrl+t",
"command_list": "ctrl+p",
"agent_list": "a",
"agent_cycle": "tab",
diff --git a/packages/web/src/content/docs/models.mdx b/packages/web/src/content/docs/models.mdx
index 2077b8e0b2e..e070f1e2e86 100644
--- a/packages/web/src/content/docs/models.mdx
+++ b/packages/web/src/content/docs/models.mdx
@@ -35,15 +35,13 @@ Consider using one of the models we recommend.
However, there are only a few of them that are good at both generating code and tool calling.
-Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list):
+Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list nor is it necessarily up to date):
-- GPT 5.1
+- GPT 5.2
- GPT 5.1 Codex
+- Claude Opus 4.5
- Claude Sonnet 4.5
-- Claude Haiku 4.5
-- Kimi K2
-- GLM 4.6
-- Qwen3 Coder
+- Minimax M2.1
- Gemini 3 Pro
---
@@ -107,30 +105,88 @@ The built-in provider and model names can be found on [Models.dev](https://model
You can also configure these options for any agents that you are using. The agent config overrides any global options here. [Learn more](/docs/agents/#additional).
-You can also define custom models that extend built-in ones and can optionally use specific options by referring to their id:
+You can also define custom variants that extend built-in ones. Variants let you configure different settings for the same model without creating duplicate entries:
-```jsonc title="opencode.jsonc" {6-20}
+```jsonc title="opencode.jsonc" {6-21}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"models": {
- "gpt-5-high": {
- "id": "gpt-5",
- "name": "MyGPT5 (High Reasoning)",
- "options": {
- "reasoningEffort": "high",
- "textVerbosity": "low",
- "reasoningSummary": "auto",
+ "gpt-5": {
+ "variants": {
+ "high": {
+ "reasoningEffort": "high",
+ "textVerbosity": "low",
+ "reasoningSummary": "auto",
+ },
+ "low": {
+ "reasoningEffort": "low",
+ "textVerbosity": "low",
+ "reasoningSummary": "auto",
+ },
},
},
- "gpt-5-low": {
- "id": "gpt-5",
- "name": "MyGPT5 (Low Reasoning)",
- "options": {
- "reasoningEffort": "low",
- "textVerbosity": "low",
- "reasoningSummary": "auto",
+ },
+ },
+ },
+}
+```
+
+---
+
+## Variants
+
+Many models support multiple variants with different configurations. OpenCode ships with built-in default variants for popular providers.
+
+### Built-in variants
+
+OpenCode ships with default variants for many providers:
+
+**Anthropic**:
+
+- `high` - High thinking budget (default)
+- `max` - Maximum thinking budget
+
+**OpenAI**:
+
+Varies by model but roughly:
+
+- `none` - No reasoning
+- `minimal` - Minimal reasoning effort
+- `low` - Low reasoning effort
+- `medium` - Medium reasoning effort
+- `high` - High reasoning effort
+- `xhigh` - Extra high reasoning effort
+
+**Google**:
+
+- `low` - Lower effort/token budget
+- `high` - Higher effort/token budget
+
+:::tip
+This list is not comprehensive. Many other providers have built-in defaults too.
+:::
+
+### Custom variants
+
+You can override existing variants or add your own:
+
+```jsonc title="opencode.jsonc" {7-18}
+{
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "openai": {
+ "models": {
+ "gpt-5": {
+ "variants": {
+ "thinking": {
+ "reasoningEffort": "high",
+ "textVerbosity": "low",
+ },
+ "fast": {
+ "disabled": true,
+ },
},
},
},
@@ -139,6 +195,10 @@ You can also define custom models that extend built-in ones and can optionally u
}
```
+### Cycle variants
+
+Use the keybind `variant_cycle` to quickly switch between variants. [Learn more](/docs/keybinds).
+
---
## Loading models
diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx
index 2568ade35cb..a61d7bae157 100644
--- a/packages/web/src/content/docs/server.mdx
+++ b/packages/web/src/content/docs/server.mdx
@@ -13,16 +13,23 @@ The `opencode serve` command runs a headless HTTP server that exposes an OpenAPI
### Usage
```bash
-opencode serve [--port ] [--hostname ]
+opencode serve [--port ] [--hostname ] [--cors ]
```
#### Options
-| Flag | Description | Default |
-| ------------ | --------------------- | ----------- |
-| `--port` | Port to listen on | `4096` |
-| `--hostname` | Hostname to listen on | `127.0.0.1` |
-| `--mdns` | Enable mDNS discovery | `false` |
+| Flag | Description | Default |
+| ------------ | ----------------------------------- | ----------- |
+| `--port` | Port to listen on | `4096` |
+| `--hostname` | Hostname to listen on | `127.0.0.1` |
+| `--mdns` | Enable mDNS discovery | `false` |
+| `--cors` | Additional browser origins to allow | `[]` |
+
+`--cors` can be passed multiple times:
+
+```bash
+opencode serve --cors http://localhost:5173 --cors https://app.example.com
+```
---
diff --git a/script/sync/fork-features.json b/script/sync/fork-features.json
index 303ea36fc14..8e8c456171c 100644
--- a/script/sync/fork-features.json
+++ b/script/sync/fork-features.json
@@ -1,8 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "Fork-specific features from upstream PRs that must be preserved during merges",
- "lastUpdated": "2025-12-30",
- "lastChange": "Removed Open Project (uses native file picker, doesn't work in web). Add Project dialog now the single entry point.",
+ "lastUpdated": "2025-12-31",
+ "lastChange": "Added PR #6476 - allow users to edit suggested changes before applying",
"note": "Replaced PR 5563 (Ask Tool) with PR 5958 (askquestion tool) which fixes race conditions and improves UX",
"forkDependencies": {
"description": "NPM dependencies added by fork features that MUST be preserved during package.json merges. These are frequently lost when accepting upstream version bumps.",
@@ -78,6 +78,44 @@
}
],
"features": [
+ {
+ "pr": 6476,
+ "title": "Allow users to edit suggested changes before applying",
+ "author": "dmmulroy",
+ "status": "open",
+ "description": "When permission is set to ask, users can press 'e' to open their editor and modify the suggested changes before accepting. Changes are tracked and the model is informed that user modified the edit.",
+ "files": [
+ "packages/opencode/src/permission/editor.ts",
+ "packages/opencode/src/permission/index.ts",
+ "packages/opencode/src/tool/edit.ts",
+ "packages/opencode/src/cli/cmd/tui/routes/session/index.tsx",
+ "packages/opencode/src/util/text.ts",
+ "packages/opencode/src/config/config.ts"
+ ],
+ "criticalCode": [
+ {
+ "file": "packages/opencode/src/permission/editor.ts",
+ "description": "PermissionEditor namespace with canEdit, getContent, getExtension, getStartLine, hasChanges, computeDiff",
+ "markers": ["export namespace PermissionEditor", "SingleFileMetadata", "SingleFileModifyData"]
+ },
+ {
+ "file": "packages/opencode/src/cli/cmd/tui/routes/session/index.tsx",
+ "description": "handleEditPermission function and permission_edit keybind handler",
+ "markers": ["handleEditPermission", "permission_edit", "PermissionEditor.canEdit"]
+ }
+ ]
+ },
+ {
+ "pr": 6507,
+ "title": "Optimize Ripgrep.tree() for large repositories",
+ "author": "Karavil",
+ "status": "open",
+ "description": "109x performance improvement for Ripgrep.tree() in large repositories by processing ripgrep output line-by-line instead of accumulating in memory",
+ "files": [
+ "packages/opencode/src/file/ripgrep.ts",
+ "packages/opencode/test/file/ripgrep.test.ts"
+ ]
+ },
{
"pr": 5958,
"title": "AskQuestion tool for user input",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 1534fe500c3..b004158160b 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.0.218",
+ "version": "1.0.220",
"publisher": "sst-dev",
"repository": {
"type": "git",