Compare commits

..
Author SHA1 Message Date
Aiden Cline 2afb05fd15 cleanup
test / test (push) Has been cancelled
2025-12-30 16:32:22 -06:00
Aiden Cline c460a7d957 fix: test 2025-12-30 16:29:59 -06:00
Aiden Cline 8a2e0a0471 rm dead code 2025-12-30 16:26:29 -06:00
Aiden Cline 5cdd764123 cleanup + test 2025-12-30 16:23:16 -06:00
Aiden Cline 938a53b5a2 fix: ensure variants can be disabled 2025-12-30 16:12:01 -06:00
Aiden Cline 5e1986c0bc test: add transform tests 2025-12-30 15:38:17 -06:00
Daniel Smolsky fc2a5b851b fix: auto-generate variants for user-configured custom provider models 2025-12-30 15:28:06 -05:00
76 changed files with 531 additions and 1459 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
mise use -g github:sst/opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
-1
View File
@@ -186,4 +186,3 @@
| 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) |
+15 -24
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.220",
"version": "1.0.217",
"bin": {
"opencode": "./bin/opencode",
},
@@ -271,7 +271,6 @@
"@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",
@@ -348,7 +347,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -368,7 +367,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.220",
"version": "1.0.217",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -379,7 +378,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -392,7 +391,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -430,7 +429,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"zod": "catalog:",
},
@@ -441,7 +440,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -572,8 +571,6 @@
"@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=="],
@@ -3920,12 +3917,6 @@
"@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=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
"nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY="
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.220",
"version": "1.0.217",
"description": "",
"type": "module",
"exports": {
+215
View File
@@ -0,0 +1,215 @@
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLayout } from "@/context/layout"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<A
href="/"
classList={{
"hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
"items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<Show when={layout.projects.list().length > 0 && params.dir}>
{(directory) => {
const currentDirectory = createMemo(() => base64Decode(directory()))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={props.navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return (
<Show when={url()}>
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</>
)
}}
</Show>
</div>
</header>
)
}
+6 -27
View File
@@ -1126,7 +1126,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
if (isShellMode) {
sdk.client.session
@@ -1154,7 +1153,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((e) => {
console.error("Failed to send command", e)
@@ -1191,7 +1189,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model,
messageID,
parts: requestParts,
variant,
})
.catch((e) => {
console.error("Failed to send prompt", e)
@@ -1378,14 +1375,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Tooltip
placement="top"
value={
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span>Choose model</span>
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</div>
<Show when={local.model.current()?.provider.name}>
<span class="text-text-weak">{local.model.current()?.provider.name}</span>
</Show>
<div class="flex items-center gap-2">
<span>Choose model</span>
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</div>
}
>
@@ -1399,25 +1391,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
>
{local.model.current()?.name ?? "Select model"}
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
{local.model.current()?.provider.name}
</span>
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
<Show when={local.model.variant.list().length > 0}>
<Tooltip placement="top" value="Cycle effort level">
<Button
variant="ghost"
onClick={() => local.model.variant.cycle()}
classList={{
"text-icon-warning": !!local.model.variant.current(),
}}
>
<Icon name="brain" size="small" />
<Show when={local.model.variant.current()}>
<span class="text-12-regular">{local.model.variant.current()}</span>
</Show>
</Button>
</Tooltip>
</Show>
</Match>
</Switch>
</div>
@@ -0,0 +1,53 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-weak">{server.name}</span>
</Button>
</div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}
-11
View File
@@ -30,8 +30,6 @@ type SessionTabs = {
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -51,7 +49,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
session: {
width: 600,
@@ -159,14 +156,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
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)
},
-41
View File
@@ -115,11 +115,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
@@ -274,45 +272,6 @@ 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])
},
},
}
})()
-2
View File
@@ -117,12 +117,10 @@ 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()
},
+1 -1
View File
@@ -63,7 +63,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: 1000 })),
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
+3 -39
View File
@@ -2,7 +2,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } 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"
@@ -182,25 +181,6 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (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 (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -218,25 +198,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label="Error Details"
hideLabel
/>
<div class="flex items-center gap-3">
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<Show when={platform.checkUpdate}>
<Show
when={store.version}
fallback={
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
{store.checking ? "Checking..." : "Check for updates"}
</Button>
}
>
<Button size="large" onClick={installUpdate}>
Update to {store.version}
</Button>
</Show>
</Show>
</div>
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Home() {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
+7 -10
View File
@@ -26,7 +26,6 @@ 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"
@@ -46,7 +45,7 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
@@ -55,7 +54,6 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -90,7 +88,6 @@ 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()
@@ -479,7 +476,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
@@ -874,11 +871,6 @@ export default function Layout(props: ParentProps) {
return (
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Show when={!sidebarProps.mobile}>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<Tooltip
class="shrink-0"
@@ -1024,6 +1016,11 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Header
navigateToProject={navigateToProject}
navigateToSession={navigateToSession}
onMobileMenuToggle={mobileSidebar.toggle}
/>
<div class="flex-1 min-h-0 flex">
<div
classList={{
+9 -224
View File
@@ -51,26 +51,17 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { StatusBar } from "@/components/status-bar"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { useServer } from "@/context/server"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { useGlobalSDK } from "@/context/global-sdk"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Session } from "@opencode-ai/sdk/v2/client"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
@@ -78,212 +69,6 @@ function same<T>(a: readonly T[], b: readonly T[]) {
return a.every((x, i) => x === b[i])
}
function Header(props: { onMobileMenuToggle?: () => void }) {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -933,7 +718,6 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<Header />
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
<Switch>
<Match when={!params.id}>
@@ -958,8 +742,6 @@ export default function Page() {
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
classes={{
root: "pb-32",
header: "px-4",
@@ -1085,8 +867,7 @@ export default function Page() {
container: "px-6",
}}
diffs={diffs()}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
split
/>
</div>
</Tabs.Content>
@@ -1218,6 +999,10 @@ export default function Page() {
</DragDropProvider>
</div>
</Show>
<StatusBar>
<SessionLspIndicator />
<SessionMcpIndicator />
</StatusBar>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -1,5 +1,5 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show, createMemo } from "solid-js"
import { createEffect, Show } 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,12 +68,6 @@ 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)
@@ -110,8 +104,8 @@ export function ReloadSection() {
}
>
<p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.220",
"version": "1.0.217",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.220",
"version": "1.0.217",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.220",
"version": "1.0.217",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+1 -4
View File
@@ -107,10 +107,7 @@ const platform: Platform = {
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
})
const notification = new Notification(title, { body: description ?? "" })
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.220",
"version": "1.0.217",
"private": true,
"type": "module",
"scripts": {
@@ -33,7 +33,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 }) => (
<WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider>
<WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
),
})),
)
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.220"
version = "1.0.217"
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.220/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.217/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.220/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.220/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.217/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.220/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.217/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.220/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.220",
"version": "1.0.217",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.220",
"version": "1.0.217",
"name": "opencode",
"type": "module",
"private": true,
@@ -66,7 +66,6 @@
"@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",
@@ -1,51 +0,0 @@
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 <name>",
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<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
}
return resolved
}
@@ -8,7 +8,6 @@ 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",
@@ -21,7 +20,6 @@ export const DebugCommand = cmd({
.command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand)
.command(AgentCommand)
.command(PathsCommand)
.command({
command: "wait",
+2 -89
View File
@@ -20,17 +20,6 @@ interface SessionStats {
}
}
toolUsage: Record<string, number>
modelUsage: Record<
string,
{
messages: number
tokens: {
input: number
output: number
}
cost: number
}
>
dateRange: {
earliest: number
latest: number
@@ -54,9 +43,6 @@ 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",
@@ -65,15 +51,7 @@ export const StatsCommand = cmd({
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
displayStats(stats, args.tools)
})
},
})
@@ -143,7 +121,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
},
},
toolUsage: {},
modelUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
@@ -177,43 +154,17 @@ 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<string, number> = {}
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)
}
}
@@ -230,7 +181,6 @@ 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,
}
@@ -254,20 +204,6 @@ 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
}
}
}
@@ -292,7 +228,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
export function displayStats(stats: SessionStats, toolLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -331,29 +267,6 @@ export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit
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)
@@ -259,7 +259,7 @@ export function Autocomplete(props: {
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
display: "/" + command.name,
description: command.description,
onSelect: () => {
const newText = "/" + command.name + " "
@@ -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}@agent-name{/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.",
@@ -253,7 +253,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",
@@ -273,7 +273,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",
@@ -1716,8 +1716,8 @@ ToolRegistry.register<typeof TaskTool>({
</box>
</Show>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
</text>
</>
)
+1 -11
View File
@@ -17,12 +17,6 @@ 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<typeof options>
@@ -36,7 +30,6 @@ 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)
@@ -45,9 +38,6 @@ 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, cors }
return { hostname, port, mdns }
}
+6 -57
View File
@@ -1,3 +1,4 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Config } from "../config/config"
@@ -5,7 +6,6 @@ import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
export namespace Command {
export const Event = {
@@ -26,29 +26,13 @@ export namespace Command {
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
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()),
template: z.string(),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
// 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<z.infer<typeof Info>, "template"> & { template: Promise<string> | 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 type Info = z.infer<typeof Info>
export const Default = {
INIT: "init",
@@ -62,19 +46,13 @@ export namespace Command {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
@@ -84,37 +62,8 @@ export namespace Command {
agent: command.agent,
model: command.model,
description: command.description,
get template() {
return command.template
},
template: command.template,
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
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<string>(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}`) ?? [],
}
}
+23 -11
View File
@@ -92,6 +92,8 @@ export namespace Config {
const promises: Promise<void>[] = []
for (const dir of unique(directories)) {
await assertValid(dir)
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
@@ -153,6 +155,23 @@ export namespace Config {
}
})
const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`)
async function assertValid(dir: string) {
const invalid = await Array.fromAsync(
INVALID_DIRS.scan({
onlyFiles: false,
cwd: dir,
}),
)
for (const item of invalid) {
throw new ConfigDirectoryTypoError({
path: dir,
dir: item,
suggestion: item.substring(0, item.length - 1),
})
}
}
async function installDependencies(dir: string) {
if (Installation.isLocal()) return
@@ -178,7 +197,7 @@ export namespace Config {
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({
@@ -216,7 +235,7 @@ export namespace Config {
return result
}
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
@@ -259,7 +278,7 @@ export namespace Config {
return result
}
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
const MODE_GLOB = new Bun.Glob("mode/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({
@@ -288,7 +307,7 @@ export namespace Config {
return result
}
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
@@ -586,7 +605,6 @@ 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({
@@ -845,12 +863,6 @@ 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(),
})
+8 -12
View File
@@ -5,6 +5,8 @@ import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
@@ -366,7 +368,7 @@ export namespace Ripgrep {
}
export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) {
const args = [await filepath(), "--json", "--hidden", "--glob=!.git/*"]
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.glob) {
for (const g of input.glob) {
@@ -381,21 +383,15 @@ export namespace Ripgrep {
args.push("--")
args.push(input.pattern)
const proc = Bun.spawn(args, {
cwd: input.cwd,
stdout: "pipe",
stderr: "ignore",
})
const output = await Bun.readableStreamToText(proc.stdout)
await proc.exited
if (proc.exitCode !== 0) {
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/).filter(Boolean)
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
@@ -331,12 +331,3 @@ 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
},
}
-23
View File
@@ -98,9 +98,6 @@ export namespace LSPClient {
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
@@ -154,16 +151,6 @@ export namespace LSPClient {
const version = files[input.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", {
@@ -180,16 +167,6 @@ export namespace LSPClient {
return
}
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
+7 -94
View File
@@ -4,11 +4,7 @@ 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 {
CallToolResultSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { 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"
@@ -97,7 +93,7 @@ export namespace MCP {
}
// Convert MCP tool definition to AI SDK Tool type
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object"
@@ -107,23 +103,15 @@ 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<string, unknown>,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
timeout: config.experimental?.mcp_timeout,
},
)
return client.callTool({
name: mcpTool.name,
arguments: args as Record<string, unknown>,
})
},
})
}
@@ -132,9 +120,6 @@ export namespace MCP {
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -179,29 +164,6 @@ 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<string, PromptInfo & { client: string }> = {}
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)
@@ -512,61 +474,12 @@ 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] = await convertMcpTool(mcpTool, client)
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
}
}
return result
}
export async function prompts() {
const s = await state()
const clientsSnapshot = await clients()
const prompts = Object.fromEntries<PromptInfo & { client: string }>(
(
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<string, string>) {
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.
@@ -34,7 +34,6 @@ 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 {
@@ -59,7 +58,6 @@ 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,
}
@@ -1018,7 +1016,6 @@ export namespace Provider {
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
+9 -16
View File
@@ -3,7 +3,6 @@ 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<ModelsDev.Model["modalities"]>["input"][number]
@@ -230,6 +229,7 @@ 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,10 +238,7 @@ export namespace ProviderTransform {
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("minimax-m2")) {
if (id.includes("m2.1")) return 40
return 20
}
if (id.includes("minimax-m2")) return 20
if (id.includes("gemini")) return 64
return undefined
}
@@ -295,17 +292,13 @@ 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 = 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
})
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")
}
return Object.fromEntries(
openaiEfforts.map((effort) => [
effort,
+4 -10
View File
@@ -45,6 +45,7 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
@@ -57,7 +58,6 @@ 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")
@@ -111,16 +111,12 @@ export namespace Server {
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
if (input === "tauri://localhost") return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input
}
if (_corsWhitelist.includes(input)) {
return input
}
return
},
}),
@@ -2680,9 +2676,7 @@ export namespace Server {
return result
}
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
_corsWhitelist = opts.cors ?? []
export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
const args = {
hostname: opts.hostname,
idleTimeout: 0,
@@ -2708,7 +2702,7 @@ export namespace Server {
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!, `opencode-${server.port!}`)
MDNS.publish(server.port!)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
+2 -4
View File
@@ -1290,9 +1290,7 @@ export namespace SessionPrompt {
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = await command.template
const placeholders = templateCommand.match(placeholderRegex) ?? []
const placeholders = command.template.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
@@ -1300,7 +1298,7 @@ export namespace SessionPrompt {
}
// Let the final placeholder swallow any extra arguments so prompts read naturally
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
+1 -1
View File
@@ -35,7 +35,7 @@ export namespace Skill {
}),
)
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
export const state = Instance.state(async () => {
-331
View File
@@ -1,331 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Ripgrep } from "../../src/file/ripgrep"
import { tmpdir } from "../fixture/fixture"
describe("Ripgrep.tree", () => {
test("generates tree for flat directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "a.ts"), "content")
await Bun.write(path.join(dir, "b.ts"), "content")
await Bun.write(path.join(dir, "c.ts"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
const lines = result.split("\n")
expect(lines).toContain("a.ts")
expect(lines).toContain("b.ts")
expect(lines).toContain("c.ts")
})
test("generates tree with nested directories", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "src", "index.ts"), "content")
await Bun.write(path.join(dir, "src", "utils", "helper.ts"), "content")
await Bun.write(path.join(dir, "README.md"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
expect(result).toContain("src/")
expect(result).toContain("index.ts")
expect(result).toContain("utils/")
expect(result).toContain("helper.ts")
expect(result).toContain("README.md")
})
test("sorts directories before files", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "aaa.txt"), "content")
await Bun.write(path.join(dir, "zzz", "file.txt"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
const lines = result.split("\n")
const dirIndex = lines.findIndex((l) => l.includes("zzz/"))
const fileIndex = lines.findIndex((l) => l.includes("aaa.txt"))
expect(dirIndex).toBeLessThan(fileIndex)
})
test("sorts alphabetically within same type", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "c.txt"), "content")
await Bun.write(path.join(dir, "a.txt"), "content")
await Bun.write(path.join(dir, "b.txt"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
const lines = result.split("\n").filter(Boolean)
const aIndex = lines.findIndex((l) => l.includes("a.txt"))
const bIndex = lines.findIndex((l) => l.includes("b.txt"))
const cIndex = lines.findIndex((l) => l.includes("c.txt"))
expect(aIndex).toBeLessThan(bIndex)
expect(bIndex).toBeLessThan(cIndex)
})
test("respects limit parameter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (let i = 0; i < 100; i++) {
await Bun.write(path.join(dir, `file${i.toString().padStart(3, "0")}.txt`), "content")
}
},
})
const result = await Ripgrep.tree({ cwd: tmp.path, limit: 10 })
expect(result).toContain("[")
expect(result).toContain("truncated]")
})
test("excludes .opencode directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "src", "index.ts"), "content")
await Bun.write(path.join(dir, ".opencode", "config.json"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
expect(result).not.toContain(".opencode")
expect(result).toContain("src/")
})
test("handles empty directory", async () => {
await using tmp = await tmpdir({ git: true })
const result = await Ripgrep.tree({ cwd: tmp.path })
expect(result).toBe("")
})
test("indents nested items correctly", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "a", "b", "c.txt"), "content")
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
const lines = result.split("\n")
const aLine = lines.find((l) => l.includes("a/"))
const bLine = lines.find((l) => l.includes("b/"))
const cLine = lines.find((l) => l.includes("c.txt"))
expect(aLine).toBe("a/")
expect(bLine).toBe("\tb/")
expect(cLine).toBe("\t\tc.txt")
})
test("default limit is 50", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (let i = 0; i < 60; i++) {
await Bun.write(path.join(dir, `file${i.toString().padStart(3, "0")}.txt`), "content")
}
},
})
const result = await Ripgrep.tree({ cwd: tmp.path })
expect(result).toContain("truncated]")
})
})
describe("Ripgrep.files", () => {
test("lists files in directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file1.ts"), "content")
await Bun.write(path.join(dir, "file2.ts"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
expect(files).toContain("file1.ts")
expect(files).toContain("file2.ts")
})
test("respects glob filter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file1.ts"), "content")
await Bun.write(path.join(dir, "file2.js"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, glob: ["*.ts"] }))
expect(files).toContain("file1.ts")
expect(files).not.toContain("file2.js")
})
test("includes hidden files by default", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, ".hidden"), "content")
await Bun.write(path.join(dir, "visible"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
expect(files).toContain(".hidden")
expect(files).toContain("visible")
})
test("respects maxDepth option", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "top.txt"), "content")
await Bun.write(path.join(dir, "level1", "mid.txt"), "content")
await Bun.write(path.join(dir, "level1", "level2", "deep.txt"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, maxDepth: 1 }))
expect(files).toContain("top.txt")
expect(files).not.toContain(path.join("level1", "mid.txt"))
})
test("throws for non-existent directory", async () => {
const nonexistent = "/tmp/nonexistent-dir-" + Math.random().toString(36).slice(2)
expect(Array.fromAsync(Ripgrep.files({ cwd: nonexistent }))).rejects.toThrow()
})
test("excludes .git directory by default", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "src.ts"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
const gitFiles = files.filter((f) => f.includes(".git"))
expect(gitFiles.length).toBe(0)
expect(files).toContain("src.ts")
})
test("respects exclude glob pattern", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "content")
await Bun.write(path.join(dir, "ignore.test.ts"), "content")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, glob: ["!*.test.ts"] }))
expect(files).toContain("keep.ts")
expect(files).not.toContain("ignore.test.ts")
})
})
describe("Ripgrep.search", () => {
test("finds matches in files", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.ts"), "function hello() { return 'world' }")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "hello" })
expect(results.length).toBeGreaterThan(0)
expect(results[0].path.text).toBe("test.ts")
expect(results[0].lines.text).toContain("hello")
})
test("returns empty array for no matches", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.ts"), "function hello() {}")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "nonexistentpattern123" })
expect(results).toEqual([])
})
test("respects limit parameter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const content = "match\nmatch\nmatch\nmatch\nmatch"
await Bun.write(path.join(dir, "test.txt"), content)
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "match", limit: 2 })
expect(results.length).toBe(2)
})
test("includes line numbers", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nmatch here\nline4")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "match" })
expect(results[0].line_number).toBe(3)
})
test("includes submatches", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "world" })
expect(results[0].submatches.length).toBeGreaterThan(0)
expect(results[0].submatches[0].match.text).toBe("world")
})
test("respects glob filter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "include.ts"), "searchterm")
await Bun.write(path.join(dir, "exclude.js"), "searchterm")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "searchterm", glob: ["*.ts"] })
expect(results.length).toBe(1)
expect(results[0].path.text).toBe("include.ts")
})
test("respects exclude glob filter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "include.ts"), "searchterm")
await Bun.write(path.join(dir, "node_modules", "exclude.ts"), "searchterm")
},
})
const results = await Ripgrep.search({ cwd: tmp.path, pattern: "searchterm", glob: ["!node_modules/**"] })
expect(results.length).toBe(1)
expect(results[0].path.text).toBe("include.ts")
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
-10
View File
@@ -1177,10 +1177,6 @@ export type ServerConfig = {
* Enable mDNS service discovery
*/
mdns?: boolean
/**
* Additional domains to allow for CORS
*/
cors?: Array<string>
}
export type AgentConfig = {
@@ -1655,10 +1651,6 @@ 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
}
}
@@ -1733,10 +1725,8 @@ export type Command = {
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: Array<string>
}
export type Model = {
+13 -66
View File
@@ -3209,19 +3209,6 @@
}
},
"required": ["npm"]
},
"variants": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
"required": [
@@ -7793,13 +7780,6 @@
"mdns": {
"description": "Enable mDNS service discovery",
"type": "boolean"
},
"cors": {
"description": "Additional domains to allow for CORS",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
@@ -8081,23 +8061,6 @@
}
},
"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": {}
}
}
}
}
@@ -8774,12 +8737,6 @@
"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
}
}
}
@@ -8982,30 +8939,24 @@
"model": {
"type": "string"
},
"mcp": {
"type": "boolean"
},
"template": {
"anyOf": [
{
"type": "string"
},
{
"type": "string"
}
]
"type": "string"
},
"subtask": {
"type": "boolean"
},
"hints": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["name", "template", "hints"]
"required": ["name", "template"]
},
"Variant": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
}
},
"required": ["disabled"],
"additionalProperties": {}
},
"Model": {
"type": "object",
@@ -9203,11 +9154,7 @@
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
"$ref": "#/components/schemas/Variant"
}
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.220",
"version": "1.0.217",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
+2 -2
View File
@@ -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 { getWorkerPool } from "../pierre/worker"
import { workerPool } from "../pierre/worker"
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
@@ -21,7 +21,7 @@ export function Code<T>(props: CodeProps<T>) {
...createDefaultOptions<T>("unified"),
...others,
},
getWorkerPool("unified"),
workerPool,
),
)
+1 -1
View File
@@ -13,7 +13,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
const workerPool = useWorkerPool(props.diffStyle)
const workerPool = useWorkerPool()
let fileDiffInstance: FileDiff<T> | undefined
const cleanupFunctions: Array<() => void> = []
+10 -7
View File
@@ -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 { getWorkerPool } from "../pierre/worker"
import { workerPool } from "../pierre/worker"
// interface ThreadMetadata {
// threadId: string
@@ -20,23 +20,26 @@ export function Diff<T>(props: DiffProps<T>) {
...createDefaultOptions(props.diffStyle),
...others,
},
getWorkerPool(props.diffStyle),
workerPool,
),
)
const cleanupFunctions: Array<() => void> = []
createEffect(() => {
const diff = fileDiff()
container.innerHTML = ""
diff.render({
fileDiff().render({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations,
containerWrapper: container,
})
})
onCleanup(() => {
diff.cleanUp()
})
onCleanup(() => {
// Clean up FileDiff event handlers and dispose SolidJS components
fileDiff()?.cleanUp()
cleanupFunctions.forEach((dispose) => dispose())
})
return <div data-component="diff" style={styleVariables} ref={container} />
-1
View File
@@ -57,7 +57,6 @@ const icons = {
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
+3 -2
View File
@@ -78,9 +78,10 @@
[data-slot="user-message-text"] {
white-space: pre-wrap;
overflow: hidden;
background: var(--surface-base);
padding: 8px 12px;
background: var(--surface-inset-base);
padding: 6px 12px;
border-radius: 4px;
border: 0.5px solid var(--border-weak-base);
}
.text-text-strong {
@@ -874,6 +874,7 @@ ToolRegistry.register({
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
<div data-component="edit-trigger">
@@ -925,6 +926,7 @@ ToolRegistry.register({
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
<div data-component="write-trigger">
+6 -3
View File
@@ -7,7 +7,7 @@
all: unset;
background-color: var(--surface-base);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs-border);
box-shadow: inset 0 0 0 1px var(--border-weak-base);
margin: 0;
padding: 0;
position: relative;
@@ -23,7 +23,10 @@
[data-slot="radio-group-indicator"] {
background: var(--button-secondary-base);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs-border);
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);
content: "";
opacity: var(--indicator-opacity, 1);
position: absolute;
@@ -112,7 +115,7 @@
/* Focus state */
[data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
[data-slot="radio-group-indicator"] {
box-shadow: var(--shadow-xs-border-focus);
--indicator-focus-width: 2px;
}
/* Hide indicator when nothing is checked */
+1 -17
View File
@@ -1,6 +1,5 @@
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"
@@ -14,12 +13,8 @@ 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<string, boolean | undefined>
classes?: { root?: string; header?: string; container?: string }
@@ -33,8 +28,6 @@ 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)
}
@@ -67,15 +60,6 @@ export const SessionReview = (props: SessionReviewProps) => {
>
<div data-slot="session-review-title">Session changes</div>
<div data-slot="session-review-actions">
<Show when={props.onDiffStyleChange}>
<RadioGroup
options={["unified", "split"] as const}
current={diffStyle()}
value={(style) => style}
label={(style) => (style === "unified" ? "Unified" : "Split")}
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
/>
</Show>
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={store.open.length > 0}>Collapse all</Match>
@@ -118,7 +102,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={diffComponent}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
diffStyle={props.split ? "split" : "unified"}
before={{
name: diff.file!,
contents: diff.before!,
+1 -8
View File
@@ -78,7 +78,6 @@ function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -93,12 +92,7 @@ function AssistantMessageItem(props: {
})
const filteredParts = createMemo(() => {
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
const parts = msgParts()
if (!props.hideResponsePart) return parts
const responsePartId = props.responsePartId
@@ -562,7 +556,6 @@ export function SessionTurn(
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
/>
)}
</For>
+3 -13
View File
@@ -1,20 +1,10 @@
import type { WorkerPoolManager } from "@pierre/diffs/worker"
import { createSimpleContext } from "./helper"
export type WorkerPools = {
unified: WorkerPoolManager | undefined
split: WorkerPoolManager | undefined
}
const ctx = createSimpleContext<WorkerPools, { pools: WorkerPools }>({
const ctx = createSimpleContext<WorkerPoolManager | undefined, { pool: WorkerPoolManager | undefined }>({
name: "WorkerPool",
init: (props) => props.pools,
init: (props) => props.pool,
})
export const WorkerPoolProvider = ctx.provider
export function useWorkerPool(diffStyle: "unified" | "split" | undefined) {
const pools = ctx.use()
if (diffStyle === "split") return pools.split
return pools.unified
}
export const useWorkerPool = ctx.use
+12 -35
View File
@@ -1,15 +1,16 @@
import { WorkerPoolManager } from "@pierre/diffs/worker"
import { getOrCreateWorkerPoolSingleton, 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" })
}
function createPool(lineDiffType: "none" | "word-alt") {
const pool = new WorkerPoolManager(
{
export const workerPool: WorkerPoolManager | undefined = (() => {
if (typeof window === "undefined") {
return undefined
}
return getOrCreateWorkerPoolSingleton({
poolOptions: {
workerFactory,
// poolSize defaults to 8. More workers = more parallelism but
// also more memory. Too many can actually slow things down.
@@ -18,34 +19,10 @@ function createPool(lineDiffType: "none" | "word-alt") {
// boot up time for workers
poolSize: 2,
},
{
highlighterOptions: {
theme: "OpenCode",
lineDiffType,
// Optionally preload languages to avoid lazy-loading delays
// langs: ["typescript", "javascript", "css", "html"],
},
)
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"),
}
}
})
})()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.220",
"version": "1.0.217",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.220",
"version": "1.0.217",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+15 -18
View File
@@ -362,12 +362,11 @@ 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 |
| `--cors` | Additional browser origin(s) to allow CORS |
| Flag | Description |
| ------------ | --------------------- |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
---
@@ -408,12 +407,11 @@ opencode stats
#### Flags
| 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) |
| 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) |
---
@@ -458,12 +456,11 @@ 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 |
| `--cors` | Additional browser origin(s) to allow CORS |
| Flag | Description |
| ------------ | --------------------- |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
---
+1 -3
View File
@@ -132,8 +132,7 @@ You can configure server settings for the `opencode serve` and `opencode web` co
"server": {
"port": 4096,
"hostname": "0.0.0.0",
"mdns": true,
"cors": ["http://localhost:5173"]
"mdns": true
}
}
```
@@ -143,7 +142,6 @@ 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).
+22 -23
View File
@@ -15,29 +15,28 @@ 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 |
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
| 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 |
---
@@ -21,7 +21,6 @@ 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 |
@@ -43,7 +43,6 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"model_list": "<leader>m",
"model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2",
"variant_cycle": "ctrl+t",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",
+24 -84
View File
@@ -35,13 +35,15 @@ 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 nor is it necessarily up to date):
Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list):
- GPT 5.2
- GPT 5.1
- GPT 5.1 Codex
- Claude Opus 4.5
- Claude Sonnet 4.5
- Minimax M2.1
- Claude Haiku 4.5
- Kimi K2
- GLM 4.6
- Qwen3 Coder
- Gemini 3 Pro
---
@@ -105,26 +107,30 @@ 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 variants that extend built-in ones. Variants let you configure different settings for the same model without creating duplicate entries:
You can also define custom models that extend built-in ones and can optionally use specific options by referring to their id:
```jsonc title="opencode.jsonc" {6-21}
```jsonc title="opencode.jsonc" {6-20}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"models": {
"gpt-5": {
"variants": {
"high": {
"reasoningEffort": "high",
"textVerbosity": "low",
"reasoningSummary": "auto",
},
"low": {
"reasoningEffort": "low",
"textVerbosity": "low",
"reasoningSummary": "auto",
},
"gpt-5-high": {
"id": "gpt-5",
"name": "MyGPT5 (High Reasoning)",
"options": {
"reasoningEffort": "high",
"textVerbosity": "low",
"reasoningSummary": "auto",
},
},
"gpt-5-low": {
"id": "gpt-5",
"name": "MyGPT5 (Low Reasoning)",
"options": {
"reasoningEffort": "low",
"textVerbosity": "low",
"reasoningSummary": "auto",
},
},
},
@@ -135,72 +141,6 @@ You can also define custom variants that extend built-in ones. Variants let you
---
## 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,
},
},
},
},
},
},
}
```
### Cycle variants
Use the keybind `variant_cycle` to quickly switch between variants. [Learn more](/docs/keybinds).
---
## Loading models
When OpenCode starts up, it checks for models in the following priority order:
+6 -13
View File
@@ -13,23 +13,16 @@ The `opencode serve` command runs a headless HTTP server that exposes an OpenAPI
### Usage
```bash
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode serve [--port <number>] [--hostname <string>]
```
#### Options
| 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
```
| Flag | Description | Default |
| ------------ | --------------------- | ----------- |
| `--port` | Port to listen on | `4096` |
| `--hostname` | Hostname to listen on | `127.0.0.1` |
| `--mdns` | Enable mDNS discovery | `false` |
---
+1 -1
View File
@@ -64,7 +64,7 @@ export async function getCommits(from: string, to: string): Promise<Commit[]> {
const areas = new Set<string>()
for (const file of files.split("\n").filter(Boolean)) {
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
if (file.startsWith("packages/opencode/src/cli/cmd/tui/")) areas.add("tui")
else if (file.startsWith("packages/opencode/")) areas.add("core")
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
else if (file.startsWith("packages/desktop/")) areas.add("app")
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.220",
"version": "1.0.217",
"publisher": "sst-dev",
"repository": {
"type": "git",