diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 4c75ad2e0..37210191e 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -31,4 +31,4 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_PERMISSION: '{"bash": "deny"}' with: - model: opencode/claude-haiku-4-5 + model: opencode/claude-opus-4-5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 96b9280fb..9c44efe1b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,6 +64,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-node@v4 with: node-version: "24" diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index fe70e35fa..d5d97f4c9 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,4 +10,5 @@ "options": {}, }, }, + "mcp": {}, } diff --git a/STATS.md b/STATS.md index 93ab445e6..9d60266d2 100644 --- a/STATS.md +++ b/STATS.md @@ -169,3 +169,5 @@ | 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | | 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | | 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | +| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | +| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | diff --git a/bun.lock b/bun.lock index 5bc89cf56..c709bd83b 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -133,6 +133,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", @@ -169,7 +170,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -198,7 +199,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -214,7 +215,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.152", + "version": "1.0.153", "bin": { "opencode": "./bin/opencode", }, @@ -306,7 +307,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -326,7 +327,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.152", + "version": "1.0.153", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -337,7 +338,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -350,7 +351,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -375,7 +376,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -410,7 +411,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "zod": "catalog:", }, @@ -421,7 +422,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -477,7 +478,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -1703,7 +1704,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2009,7 +2010,7 @@ "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index e28f98d05..5f3baf191 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ=" + "nodeModules": "sha256-lgPsYtNJT7a+mDk5cTiEJLlBnTMTjxZCl8bw5WxcuaM=" } diff --git a/package.json b/package.json index 39733b931..ca2a10f78 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.3", + "packageManager": "bun@1.3.4", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", @@ -20,7 +20,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 96cd611f4..4fcaff701 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.152", + "version": "1.0.153", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index 31ce49617..a19b97aa0 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -8,7 +8,6 @@ import { Faq } from "~/component/faq" import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png" import { Legal } from "~/component/legal" import { config } from "~/config" -import { github } from "~/lib/github" function CopyStatus() { return ( @@ -20,14 +19,7 @@ function CopyStatus() { } export default function Download() { - const githubData = createAsync(() => github(), { - deferStream: true, - }) - const download = () => { - const version = githubData()?.release.tag_name - if (!version) return null - return `https://github.com/sst/opencode/releases/download/${version}` - } + const downloadUrl = "https://github.com/sst/opencode/releases/latest/download" const handleCopyClick = (command: string) => (event: Event) => { const button = event.currentTarget as HTMLButtonElement navigator.clipboard.writeText(command) @@ -115,7 +107,7 @@ export default function Download() { macOS (Apple Silicon) - + Download @@ -131,7 +123,7 @@ export default function Download() { macOS (Intel) - + Download @@ -154,7 +146,7 @@ export default function Download() { Windows (x64) - + Download @@ -170,7 +162,7 @@ export default function Download() { Linux (.deb) - + Download @@ -186,7 +178,7 @@ export default function Download() { Linux (.rpm) - + Download diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 6fd87c2f8..f68f70436 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.152", + "version": "1.0.153", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 22322aa24..53a41670d 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.152", + "version": "1.0.153", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index f26d54d35..a03e3843b 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.152", + "version": "1.0.153", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 91e04af08..0f5afeaa9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.152", + "version": "1.0.153", "description": "", "type": "module", "exports": { @@ -37,6 +37,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index bf9dfd3b7..a49dac9aa 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -1,20 +1,21 @@ import "@/index.css" +import { Show } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { Diff } from "@opencode-ai/ui/diff" -import { GlobalSyncProvider } from "./context/global-sync" +import { GlobalSyncProvider } from "@/context/global-sync" +import { LayoutProvider } from "@/context/layout" +import { GlobalSDKProvider } from "@/context/global-sdk" +import { SessionProvider } from "@/context/session" +import { NotificationProvider } from "@/context/notification" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" -import { LayoutProvider } from "./context/layout" -import { GlobalSDKProvider } from "./context/global-sdk" -import { SessionProvider } from "./context/session" -import { Show } from "solid-js" -import { NotificationProvider } from "./context/notification" declare global { interface Window { @@ -38,27 +39,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx new file mode 100644 index 000000000..4660e1398 --- /dev/null +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -0,0 +1,383 @@ +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogSelectModel } from "./dialog-select-model" + +export function DialogConnectProvider(props: { provider: string }) { + const dialog = useDialog() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const platform = usePlatform() + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) + const methods = createMemo( + () => + globalSync.data.provider_auth[props.provider] ?? [ + { + type: "api", + label: "API key", + }, + ], + ) + const [store, setStore] = createStore({ + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | ProviderAuthAuthorization, + state: "pending" as undefined | "pending" | "complete" | "error", + error: undefined as string | undefined, + }) + + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) + + async function selectMethod(index: number) { + const method = methods()[index] + setStore( + produce((draft) => { + draft.methodIndex = index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + }), + ) + + if (method.type === "oauth") { + setStore("state", "pending") + const start = Date.now() + await globalSDK.client.provider.oauth + .authorize( + { + providerID: props.provider, + method: index, + }, + { throwOnError: true }, + ) + .then((x) => { + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + setTimeout(() => { + setStore("state", "complete") + setStore("authorization", x.data!) + }, delay) + return + } + setStore("state", "complete") + setStore("authorization", x.data!) + }) + .catch((e) => { + setStore("state", "error") + setStore("error", String(e)) + }) + } + } + + let listRef: ListRef | undefined + function handleKey(e: KeyboardEvent) { + if (e.key === "Enter" && e.target instanceof HTMLInputElement) { + return + } + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + if (methods().length === 1) { + selectMethod(0) + } + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + async function complete() { + await globalSDK.client.global.dispose() + setTimeout(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) + dialog.replace(() => ) + }, 1000) + } + + function goBack() { + if (methods().length === 1) { + dialog.replace(() => ) + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("methodIndex", undefined) + return + } + if (store.methodIndex) { + setStore("methodIndex", undefined) + return + } + dialog.replace(() => ) + } + + return ( + }> +
+
+ +
+ + + Login with Claude Pro/Max + + Connect {provider().name} + +
+
+
+ + +
Select login method for {provider().name}.
+
+ (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name} models + in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } + + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + if (!error) { + await complete() + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) + + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, + }) + if (result.error) { + // TODO: show error + dialog.clear() + return + } + await complete() + }) + + return ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx new file mode 100644 index 000000000..5765a8e1a --- /dev/null +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -0,0 +1,50 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogManageModels: Component = () => { + const local = useLocal() + return ( + + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + if (!x) return + const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id }) + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) + }} + > + {(i) => ( +
+ {i.name} +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx new file mode 100644 index 000000000..0250963b0 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -0,0 +1,44 @@ +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useSession } from "@/context/session" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function DialogSelectFile() { + const session = useSession() + const local = useLocal() + const dialog = useDialog() + return ( + + x} + onSelect={(path) => { + if (path) { + session.layout.openTab("file://" + path) + } + dialog.clear() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..7cdb24915 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,119 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnectProvider } from "./dialog-connect-provider" + +export const DialogSelectModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx new file mode 100644 index 000000000..f0b2e6db9 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -0,0 +1,84 @@ +import { Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogManageModels } from "./dialog-manage-models" + +export const DialogSelectModel: Component<{ provider?: string }> = (props) => { + const local = useLocal() + const dialog = useDialog() + + const models = createMemo(() => + local.model + .list() + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + dialog.replace(() => )} + > + Connect provider + + } + > + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx new file mode 100644 index 000000000..8da10b1d5 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -0,0 +1,64 @@ +import { Component, Show } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogConnectProvider } from "./dialog-connect-provider" + +export const DialogSelectProvider: Component = () => { + const dialog = useDialog() + const providers = useProviders() + + return ( + + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 70ee0a739..296fe8b2f 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,18 +1,7 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { - createEffect, - on, - Component, - Show, - For, - onMount, - onCleanup, - Switch, - Match, - createSignal, - createMemo, -} from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" +import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" @@ -20,21 +9,16 @@ import { useSDK } from "@/context/sdk" import { useNavigate } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { Tag } from "@opencode-ai/ui/tag" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useLayout } from "@/context/layout" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { iife } from "@opencode-ai/util/iife" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { class?: string @@ -75,28 +59,91 @@ export const PromptInput: Component = (props) => { const sync = useSync() const local = useLocal() const session = useSession() - const layout = useLayout() + const dialog = useDialog() const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ popoverIsOpen: boolean + historyIndex: number + savedPrompt: Prompt | null + placeholder: number }>({ popoverIsOpen: false, + historyIndex: -1, + savedPrompt: null, + placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), }) - const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length)) + const MAX_HISTORY = 100 + const [history, setHistory] = makePersisted( + createStore<{ + entries: Prompt[] + }>({ + entries: [], + }), + { + name: "prompt-history.v1", + }, + ) - onMount(() => { - const interval = setInterval(() => { - setPlaceholder((prev) => (prev + 1) % PLACEHOLDERS.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) + const clonePromptParts = (prompt: Prompt): Prompt => + prompt.map((part) => + part.type === "text" + ? { ...part } + : { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + }, + ) + + const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + + const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(prompt) + session.prompt.set(prompt, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + }) + } + + const getCaretLineState = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + const editorRect = editorRef.getBoundingClientRect() + const style = window.getComputedStyle(editorRef) + const paddingTop = parseFloat(style.paddingTop) || 0 + const paddingBottom = parseFloat(style.paddingBottom) || 0 + let lineHeight = parseFloat(style.lineHeight) + if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 + const scrollTop = editorRef.scrollTop + let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop + if (!Number.isFinite(relativeTop)) relativeTop = scrollTop + relativeTop = Math.max(0, relativeTop) + let caretHeight = rect.height + if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight + const relativeBottom = relativeTop + caretHeight + const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) + const threshold = Math.max(2, lineHeight / 2) + + return { + collapsed: selection.isCollapsed, + onFirstLine: relativeTop <= threshold, + onLastLine: contentHeight - relativeBottom <= threshold, + } + } createEffect(() => { session.id editorRef.focus() + if (session.id) return + const interval = setInterval(() => { + setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) + }, 6500) + onCleanup(() => clearInterval(interval)) }) const isFocused = createFocusSignal(() => editorRef) @@ -129,17 +176,12 @@ export const PromptInput: Component = (props) => { addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 }) } - const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({ + const { flat, active, onInput, onKeyDown } = useFilteredList({ items: local.file.searchFilesAndDirectories, key: (x) => x, onSelect: handleFileSelect, }) - createEffect(() => { - local.model.recent() - refetch() - }) - createEffect( on( () => session.prompt.current(), @@ -221,6 +263,11 @@ export const PromptInput: Component = (props) => { setStore("popoverIsOpen", false) } + if (store.historyIndex >= 0) { + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + session.prompt.set(rawParts, cursorPosition) } @@ -296,12 +343,100 @@ export const PromptInput: Component = (props) => { sessionID: session.id!, }) + const addToHistory = (prompt: Prompt) => { + const text = prompt + .map((p) => p.content) + .join("") + .trim() + if (!text) return + + const entry = clonePromptParts(prompt) + const lastEntry = history.entries[0] + if (lastEntry) { + const lastText = lastEntry.map((p) => p.content).join("") + if (lastText === text) return + } + + setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + } + + const navigateHistory = (direction: "up" | "down") => { + const entries = history.entries + const current = store.historyIndex + + if (direction === "up") { + if (entries.length === 0) return false + if (current === -1) { + setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("historyIndex", 0) + applyHistoryPrompt(entries[0], "start") + return true + } + if (current < entries.length - 1) { + const next = current + 1 + setStore("historyIndex", next) + applyHistoryPrompt(entries[next], "start") + return true + } + return false + } + + if (current > 0) { + const next = current - 1 + setStore("historyIndex", next) + applyHistoryPrompt(entries[next], "end") + return true + } + if (current === 0) { + setStore("historyIndex", -1) + const saved = store.savedPrompt + if (saved) { + applyHistoryPrompt(saved, "end") + setStore("savedPrompt", null) + return true + } + applyHistoryPrompt(DEFAULT_PROMPT, "end") + return true + } + + return false + } + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) event.preventDefault() return } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (!collapsed) return + const cursorPos = getCursorPosition(editorRef) + const textLength = promptLength(session.prompt.current()) + const inHistory = store.historyIndex >= 0 + const isStart = cursorPos === 0 + const isEnd = cursorPos === textLength + const atAbsoluteStart = onFirstLine && isStart + const atAbsoluteEnd = onLastLine && isEnd + const allowUp = (inHistory && isEnd) || atAbsoluteStart + const allowDown = (inHistory && isStart) || atAbsoluteEnd + + if (event.key === "ArrowUp") { + if (!allowUp) return + if (navigateHistory("up")) { + event.preventDefault() + } + return + } + + if (!allowDown) return + if (navigateHistory("down")) { + event.preventDefault() + } + return + } + if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -323,6 +458,10 @@ export const PromptInput: Component = (props) => { return } + addToHistory(prompt) + setStore("historyIndex", -1) + setStore("savedPrompt", null) + let existing = session.info() if (!existing) { const created = await sdk.client.session.create() @@ -461,7 +600,7 @@ export const PromptInput: Component = (props) => { />
- Ask anything... "{PLACEHOLDERS[placeholder()]}" + Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -474,207 +613,17 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> - - - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => - layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, - ), - ) - return ( - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - } - actions={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - layout.dialog.close("model") - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
- Add more models from popular providers -
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
- Connect with Claude Pro/Max or API key -
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
-
{ pty: LocalPTY @@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void + const prefersDark = usePrefersDark() onMount(async () => { ghostty = await Ghostty.load() @@ -31,10 +33,17 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: "TX-02, monospace", allowTransparency: true, - theme: { - background: "#191515", - foreground: "#d4d4d4", - }, + theme: prefersDark() + ? { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + } + : { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + }, scrollback: 10_000, ghostty, }) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 3d5cad761..925bf4d4c 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore, produce } from "solid-js/store" -import { batch, createMemo, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,8 +22,6 @@ export function getAvatarColors(key?: string) { } } -type Dialog = "provider" | "model" | "connect" - export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -45,22 +43,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "layout.v1", + name: "layout.v2", }, ) - const [ephemeral, setEphemeral] = createStore<{ - connect: { - provider?: string - state?: "pending" | "complete" | "error" - error?: string - } - dialog: { - open?: Dialog - } - }>({ - connect: {}, - dialog: {}, - }) + const usedColors = new Set() function pickAvailableColor(): AvatarColorKey { @@ -169,58 +155,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - dialog: { - opened: createMemo(() => ephemeral.dialog?.open), - open(dialog: Dialog) { - batch(() => { - // if (dialog !== "connect") { - // setEphemeral("connect", {}) - // } - setEphemeral("dialog", "open", dialog) - }) - }, - close(dialog: Dialog) { - if (ephemeral.dialog.open === dialog) { - setEphemeral( - produce((state) => { - state.dialog.open = undefined - state.connect = {} - }), - ) - } - }, - connect(provider: string) { - setEphemeral( - produce((state) => { - state.dialog.open = "connect" - state.connect = { provider, state: "pending" } - }), - ) - }, - }, - connect: { - provider: createMemo(() => ephemeral.connect.provider), - state: createMemo(() => ephemeral.connect.state), - complete() { - setEphemeral( - produce((state) => { - state.dialog.open = "model" - state.connect.state = "complete" - }), - ) - }, - error(message: string) { - setEphemeral( - produce((state) => { - state.connect.state = "error" - state.connect.error = message - }), - ) - }, - clear() { - setEphemeral("connect", {}) - }, - }, } }, }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 39fd1f987..56154c5ba 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,12 +1,14 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo } from "solid-js" -import { uniqueBy } from "remeda" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" +import { makePersisted } from "@solid-primitives/storage" +import { DateTime } from "luxon" export type LocalFile = FileNode & Partial<{ @@ -78,7 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const agent = (() => { - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [store, setStore] = createStore<{ current: string }>({ @@ -108,30 +110,62 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const [store, setStore] = createStore<{ + const [store, setStore] = makePersisted( + createStore<{ + user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] + recent: ModelKey[] + }>({ + user: [], + recent: [], + }), + { name: "model.v1" }, + ) + + const [ephemeral, setEphemeral] = createStore<{ model: Record - recent: ModelKey[] }>({ model: {}, - recent: [], }) - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) - }) - - const list = createMemo(() => + const available = createMemo(() => providers.connected().flatMap((p) => Object.values(p.models).map((m) => ({ ...m, - name: m.name.replace("(latest)", "").trim(), provider: p, - latest: m.name.includes("(latest)"), })), ), ) + + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + })), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) const fallbackModel = createMemo(() => { @@ -163,10 +197,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ throw new Error("No default model found") }) - const currentModel = createMemo(() => { + const current = createMemo(() => { const a = agent.current() const key = getFirstValidModel( - () => store.model[a.name], + () => ephemeral.model[a.name], () => a.model, fallbackModel, )! @@ -177,10 +211,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const cycle = (direction: 1 | -1) => { const recentList = recent() - const current = currentModel() - if (!current) return + const currentModel = current() + if (!currentModel) return - const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id) + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) if (index === -1) return let next = index + direction @@ -196,14 +232,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } + function updateVisibility(model: ModelKey, visibility: "show" | "hide") { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility }) + } else { + setStore("user", store.user.length, { ...model, visibility }) + } + } + return { - current: currentModel, + current, recent, list, cycle, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { - setStore("model", agent.current().name, model ?? fallbackModel()) + setEphemeral("model", agent.current().name, model ?? fallbackModel()) if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -211,6 +256,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, + visible(model: ModelKey) { + const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID) + return ( + user?.visibility !== "hide" && + (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) || + user?.visibility === "show") + ) + }, + setVisibility(model: ModelKey, visible: boolean) { + updateVisibility(model, visible ? "show" : "hide") + }, } })() diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index c909a373d..0dbb3f6d6 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,6 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import { DialogRoot } from "@opencode-ai/ui/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -20,7 +21,9 @@ export default function Layout(props: ParentProps) { const sync = useSync() return ( - {props.children} + + {props.children} + ) })} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7da920c5f..7af562d57 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,16 +1,4 @@ -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, - type JSX, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors } from "@/context/layout" @@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Session, Project } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce } from "solid-js/store" import { @@ -40,21 +27,14 @@ import { useDragDropContext, } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" -import { Tag } from "@opencode-ai/ui/tag" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { iife } from "@opencode-ai/util/iife" -import { Link } from "@/components/link" -import { List, ListRef } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" -import { showToast, Toast } from "@opencode-ai/ui/toast" +import { useProviders } from "@/hooks/use-providers" +import { Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { Spinner } from "@opencode-ai/ui/spinner" 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 { DialogSelectProvider } from "@/components/dialog-select-provider" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -70,6 +50,11 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const navigate = useNavigate() const providers = useProviders() + const dialog = useDialog() + + function connectProvider() { + dialog.replace(() => ) + } function navigateToProject(directory: string | undefined) { if (!directory) return @@ -110,10 +95,6 @@ export default function Layout(props: ParentProps) { } } - async function connectProvider() { - layout.dialog.open("provider") - } - createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -189,11 +170,13 @@ export default function Layout(props: ParentProps) { const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => getFilename(props.project.worktree)) const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + return (
- -
+ -
{props.children}
- - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") - } - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
-
- - {iife(() => { - const providerID = createMemo(() => layout.connect.provider()!) - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) - const methods = createMemo( - () => - globalSync.data.provider_auth[providerID()] ?? [ - { - type: "api", - label: "API key", - }, - ], - ) - const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, - authorization: undefined as undefined | ProviderAuthAuthorization, - state: "pending" as undefined | "pending" | "complete" | "error", - error: undefined as string | undefined, - }) - - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) - - async function selectMethod(index: number) { - const method = methods()[index] - setStore( - produce((draft) => { - draft.method = method - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) - - if (method.type === "oauth") { - setStore("state", "pending") - const start = Date.now() - await globalSDK.client.provider.oauth - .authorize( - { - providerID: providerID(), - method: index, - }, - { throwOnError: true }, - ) - .then((x) => { - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - setTimeout(() => { - setStore("state", "complete") - setStore("authorization", x.data!) - }, delay) - return - } - setStore("state", "complete") - setStore("authorization", x.data!) - }) - .catch((e) => { - setStore("state", "error") - setStore("error", String(e)) - }) - } - } - - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Enter" && e.target instanceof HTMLInputElement) { - return - } - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - if (methods().length === 1) { - selectMethod(0) - } - - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - async function complete() { - await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) - } - - return ( - { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") - } - }} - > - - - { - if (methods().length === 1) { - layout.dialog.open("provider") - return - } - if (store.authorization) { - setStore("authorization", undefined) - setStore("method", undefined) - return - } - if (store.method) { - setStore("method", undefined) - return - } - layout.dialog.open("provider") - }} - /> - - - - -
-
- -
- - - Login with Claude Pro/Max - - Connect {provider().name} - -
-
-
- - -
Select login method for {provider().name}.
-
- (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {i.label} -
- )} - -
- - -
-
- - Authorization in progress... -
-
-
- -
-
- - Authorization failed: {store.error} -
-
-
- - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: providerID(), - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. -
-
- With a single API key you’ll get access to models such as Claude, GPT, Gemini, - GLM and more. -
-
- Visit{" "} - - opencode.ai/zen - {" "} - to collect your API key. -
-
-
- -
- Enter your {provider().name} API key to connect your account and use{" "} - {provider().name} models in OpenCode. -
-
-
-
- - - -
- ) - })} -
- - - - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", "Authorization code is required") - return - } - - setFormStore("error", undefined) - const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") - } - - return ( -
-
- Visit this link to collect your - authorization code to connect your account and use {provider().name} models in - OpenCode. -
-
- - - -
- ) - })} -
- - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(async () => { - const result = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - layout.dialog.close("connect") - return - } - await complete() - }) - - return ( -
-
- Visit this link and enter the code - below to connect your account and use {provider().name} models in OpenCode. -
- -
- - Waiting for authorization... -
-
- ) - })} -
-
-
- -
-
- -
- ) - })} -
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 8ea9f87e1..a21135f76 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -15,7 +15,6 @@ import { Code } from "@opencode-ai/ui/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { SessionReview } from "@opencode-ai/ui/session-review" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { DragDropProvider, DragDropSensors, @@ -33,15 +32,17 @@ import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectFile } from "@/components/dialog-select-file" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() const session = useSession() + const dialog = useDialog() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, - fileSelectOpen: false, activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, }) @@ -72,7 +73,7 @@ export default function Page() { } if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") { event.preventDefault() - setStore("fileSelectOpen", true) + dialog.replace(() => ) return } if (event.ctrlKey && event.key.toLowerCase() === "t") { @@ -388,7 +389,7 @@ export default function Page() { icon="plus-small" variant="ghost" iconSize="large" - onClick={() => setStore("fileSelectOpen", true)} + onClick={() => dialog.replace(() => )} />
@@ -610,42 +611,6 @@ export default function Page() { - - x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
-
- )} -
-
{ export default function () { const params = useParams() - const data = createAsync( - async () => { - if (!params.shareID) throw new Error("Missing shareID") - const now = Date.now() - const data = getData(params.shareID) - console.log("getData", Date.now() - now) - return data - }, - { - deferStream: true, - }, - ) + const data = createAsync(async () => { + if (!params.shareID) throw new Error("Missing shareID") + const now = Date.now() + const data = getData(params.shareID) + console.log("getData", Date.now() - now) + return data + }) createEffect(() => { console.log(data()) diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 649233b99..52f948655 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.152" +version = "1.0.153" 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.152/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.153/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.152/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.153/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.152/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.153/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.152/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 42baa2787..c2ee790ce 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.152", + "version": "1.0.153", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/Dockerfile b/packages/opencode/Dockerfile index 99f593581..f92b48a6d 100644 --- a/packages/opencode/Dockerfile +++ b/packages/opencode/Dockerfile @@ -1,10 +1,18 @@ -FROM alpine +FROM alpine AS base # Disable the runtime transpiler cache by default inside Docker containers. # On ephemeral containers, the cache is not useful ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH} RUN apk add libgcc libstdc++ ripgrep -ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-amd64 +COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-arm64 +COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode + +ARG TARGETARCH +FROM build-${TARGETARCH} RUN opencode --version ENTRYPOINT ["opencode"] diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 362f5b1f2..bbeb9ae0a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.152", + "version": "1.0.153", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 5a6ac2584..a85fde9e2 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -117,6 +117,9 @@ for (const item of targets) { compile: { autoloadBunfig: false, autoloadDotenv: false, + //@ts-ignore (bun types aren't up to date) + autoloadTsconfig: true, + autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, outfile: `dist/${name}/bin/opencode`, execArgv: [`--user-agent=opencode/${Script.version}`, "--"], diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index ff75bbb8d..72632992f 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -244,8 +244,8 @@ if (!Script.preview) { await $`cd ./dist/homebrew-tap && git push` const image = "ghcr.io/sst/opencode" - await $`docker build -t ${image}:${Script.version} .` - await $`docker push ${image}:${Script.version}` - await $`docker tag ${image}:${Script.version} ${image}:latest` - await $`docker push ${image}:latest` + const platforms = "linux/amd64,linux/arm64" + const tags = [`${image}:${Script.version}`, `${image}:latest`] + const tagFlags = tags.flatMap((t) => ["-t", t]) + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 94127e51c..ef007df13 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -2,18 +2,24 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" -import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { mergeDeep } from "remeda" +import PROMPT_GENERATE from "./generate.txt" +import PROMPT_COMPACTION from "./prompt/compaction.txt" +import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SUMMARY from "./prompt/summary.txt" +import PROMPT_TITLE from "./prompt/title.txt" + export namespace Agent { export const Info = z .object({ name: z.string(), description: z.string().optional(), mode: z.enum(["subagent", "primary", "all"]), - builtIn: z.boolean(), + native: z.boolean().optional(), + hidden: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -112,7 +118,8 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + hidden: true, }, explore: { name: "explore", @@ -124,30 +131,23 @@ export namespace Agent { ...defaultTools, }, description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: [ - `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`, - ``, - `Your strengths:`, - `- Rapidly finding files using glob patterns`, - `- Searching code and text with powerful regex patterns`, - `- Reading and analyzing file contents`, - ``, - `Guidelines:`, - `- Use Glob for broad file pattern matching`, - `- Use Grep for searching file contents with regex`, - `- Use Read when you know the specific file path you need to read`, - `- Use Bash for file operations like copying, moving, or listing directory contents`, - `- Adapt your search approach based on the thoroughness level specified by the caller`, - `- Return file paths as absolute paths in your final response`, - `- For clear communication, avoid using emojis`, - `- Do not create any files, or run bash commands that modify the user's system state in any way`, - ``, - `Complete the user's search request efficiently and report your findings clearly.`, - ].join("\n"), + prompt: PROMPT_EXPLORE, options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + tools: { + "*": false, + }, + options: {}, + permission: agentPermission, }, build: { name: "build", @@ -155,7 +155,27 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "primary", - builtIn: true, + native: true, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_TITLE, + tools: {}, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_SUMMARY, + tools: {}, }, plan: { name: "plan", @@ -165,7 +185,7 @@ export namespace Agent { ...defaultTools, }, mode: "primary", - builtIn: true, + native: true, }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { @@ -181,7 +201,7 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, - builtIn: false, + native: false, } const { name, diff --git a/packages/opencode/src/session/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt similarity index 100% rename from packages/opencode/src/session/prompt/compaction.txt rename to packages/opencode/src/agent/prompt/compaction.txt diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt new file mode 100644 index 000000000..5761077cb --- /dev/null +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -0,0 +1,18 @@ +You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly. diff --git a/packages/opencode/src/session/prompt/summarize.txt b/packages/opencode/src/agent/prompt/summary.txt similarity index 100% rename from packages/opencode/src/session/prompt/summarize.txt rename to packages/opencode/src/agent/prompt/summary.txt diff --git a/packages/opencode/src/session/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt similarity index 84% rename from packages/opencode/src/session/prompt/title.txt rename to packages/opencode/src/agent/prompt/title.txt index e297dc460..f67aaa95b 100644 --- a/packages/opencode/src/session/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,8 +22,8 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. “hello”, “lol”, “whats up”, “hey”): - → create a title that reflects the user’s tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) +- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): + → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index c0f90e6ca..5456d0a5b 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -85,47 +85,16 @@ export namespace BunProc { version, }) - const total = 3 - const wait = 500 - - const runInstall = async (count: number = 1): Promise => { - log.info("bun install attempt", { - pkg, - version, - attempt: count, - total, - }) - await BunProc.run(args, { - cwd: Global.Path.cache, - }).catch(async (error) => { - log.warn("bun install failed", { - pkg, - version, - attempt: count, - total, - error, - }) - if (count >= total) { - throw new InstallFailedError( - { pkg, version }, - { - cause: error, - }, - ) - } - const delay = wait * count - log.info("bun install retrying", { - pkg, - version, - next: count + 1, - delay, - }) - await Bun.sleep(delay) - return runInstall(count + 1) - }) - } - - await runInstall() + await BunProc.run(args, { + cwd: Global.Path.cache, + }).catch((e) => { + throw new InstallFailedError( + { pkg, version }, + { + cause: e, + }, + ) + }) // Resolve actual version from installed package when using "latest" // This ensures subsequent starts use the cached version until explicitly updated diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 812e97423..2cbcfbfe9 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -227,8 +227,8 @@ const AgentListCommand = cmd({ async fn() { const agents = await Agent.list() const sortedAgents = agents.sort((a, b) => { - if (a.builtIn !== b.builtIn) { - return a.builtIn ? -1 : 1 + if (a.native !== b.native) { + return a.native ? -1 : 1 } return a.name.localeCompare(b.name) }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c57711b4c..23456c75e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -277,8 +277,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } @@ -330,8 +330,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 28e841122..69db202ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -218,7 +218,9 @@ function App() { let continued = false createEffect(() => { if (continued || sync.status !== "complete" || !args.continue) return - const match = sync.data.session.at(0)?.id + const match = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .find((x) => x.parentID === undefined)?.id if (match) { continued = true route.navigate({ type: "session", sessionID: match }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 65aaeb22b..365a22445 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -12,7 +12,7 @@ export function DialogAgent() { return { value: item.name, title: item.name, - description: item.builtIn ? "native" : item.description, + description: item.native ? "native" : item.description, } }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index c40aa114a..37e6ccda5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -184,7 +184,7 @@ export function Autocomplete(props: { const agents = createMemo(() => { const agents = sync.data.agent return agents - .filter((agent) => !agent.builtIn && agent.mode !== "primary") + .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 941b383e6..eefe43d1f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -44,6 +44,7 @@ export type PromptRef = { reset(): void blur(): void focus(): void + submit(): void } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] @@ -447,11 +448,14 @@ export function Prompt(props: PromptProps) { }) setStore("extmarkToPartIndex", new Map()) }, + submit() { + submit() + }, }) async function submit() { if (props.disabled) return - if (autocomplete.visible) return + if (autocomplete?.visible) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { @@ -869,8 +873,7 @@ export function Prompt(props: PromptProps) { borderColor={highlight()} customBorderChars={{ ...EmptyBorder, - // when the background is transparent, don't draw the vertical line - vertical: theme.background.a != 0 ? "╹" : " ", + vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", }} > { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [agentStore, setAgentStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index d0bb296eb..ecdf93c43 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -57,6 +57,7 @@ export function Home() { } else if (args.prompt) { prompt.set({ input: args.prompt, parts: [] }) once = true + prompt.submit() } }) const directory = useDirectory() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1c1e4b65e..48f7db054 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -323,10 +323,13 @@ export function Session() { keybind: "session_unshare", disabled: !session()?.share?.url, category: "Session", - onSelect: (dialog) => { - sdk.client.session.unshare({ - sessionID: route.sessionID, - }) + onSelect: async (dialog) => { + await sdk.client.session + .unshare({ + sessionID: route.sessionID, + }) + .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) + .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) dialog.clear() }, }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index b5208cd1c..b64a18ae2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -9,6 +9,7 @@ import { Global } from "@/global" import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" +import { useKV } from "../../context/kv" export function Sidebar(props: { sessionID: string }) { const sync = useSync() @@ -48,12 +49,13 @@ export function Sidebar(props: { sessionID: string }) { } }) - const keybind = useKeybind() const directory = useDirectory() + const kv = useKV() const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) + const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) return ( @@ -249,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) { - + - - Getting started - + + + Getting started + + kv.set("dismissed_getting_started", true)}> + ✕ + + OpenCode includes free models so you can start immediately. Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 42f6b11e9..333e19848 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -783,6 +783,7 @@ export namespace Config { .array(z.string()) .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"), }) .optional(), }) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 5cba5e820..770427abe 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -3,14 +3,20 @@ import { Log } from "../util/log" export namespace FileTime { const log = Log.create({ service: "file.time" }) + // Per-session read times plus per-file write locks. + // All tools that overwrite existing files should run their + // assert/read/write/update sequence inside withLock(filepath, ...) + // so concurrent writes to the same file are serialized. export const state = Instance.state(() => { const read: { [sessionID: string]: { [path: string]: Date | undefined } } = {} + const locks = new Map>() return { read, + locks, } }) @@ -25,6 +31,26 @@ export namespace FileTime { return state().read[sessionID]?.[file] } + export async function withLock(filepath: string, fn: () => Promise): Promise { + const current = state() + const currentLock = current.locks.get(filepath) ?? Promise.resolve() + let release: () => void = () => {} + const nextLock = new Promise((resolve) => { + release = resolve + }) + const chained = currentLock.then(() => nextLock) + current.locks.set(filepath, chained) + await currentLock + try { + return await fn() + } finally { + release() + if (current.locks.get(filepath) === chained) { + current.locks.delete(filepath) + } + } + } + export async function assert(sessionID: string, filepath: string) { const time = get(sessionID, filepath) if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ca1af6d84..d7a24708a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -11,6 +11,7 @@ export namespace Flag { export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") + export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 4e49bb324..c4e7c9ee8 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -284,3 +284,12 @@ export const latexindent: Info = { return Bun.which("latexindent") !== null }, } + +export const gleam: Info = { + name: "gleam", + command: ["gleam", "format", "$FILE"], + extensions: [".gleam"], + async enabled() { + return Bun.which("gleam") !== null + }, +} diff --git a/packages/opencode/src/lsp/language.ts b/packages/opencode/src/lsp/language.ts index fbf29a6f7..5261873f6 100644 --- a/packages/opencode/src/lsp/language.ts +++ b/packages/opencode/src/lsp/language.ts @@ -34,6 +34,7 @@ export const LANGUAGE_EXTENSIONS: Record = { ".gitrebase": "git-rebase", ".go": "go", ".groovy": "groovy", + ".gleam": "gleam", ".hbs": "handlebars", ".handlebars": "handlebars", ".hs": "haskell", diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 5230117ee..e3e3fdf7d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1470,4 +1470,61 @@ export namespace LSPServer { } }, } + + export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async () => Instance.directory, + async spawn(root) { + let binary = Bun.which("docker-langserver") + const args: string[] = [] + if (!binary) { + const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") + if (!(await Bun.file(js).exists())) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + cwd: Global.Path.bin, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }).exited + } + binary = BunProc.which() + args.push("run", js) + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + }) + return { + process: proc, + } + }, + } + + export const Gleam: Info = { + id: "gleam", + extensions: [".gleam"], + root: NearestRoot(["gleam.toml"]), + async spawn(root) { + const gleam = Bun.which("gleam") + if (!gleam) { + log.info("gleam not found, please install gleam first") + return + } + return { + process: spawn(gleam, ["lsp"], { + cwd: root, + }), + } + }, + } } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c523725ec..c58638d28 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -4,6 +4,7 @@ import path from "path" import z from "zod" import { data } from "./models-macro" with { type: "macro" } import { Installation } from "../installation" +import { Flag } from "../flag/flag" export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) @@ -83,6 +84,7 @@ export namespace ModelsDev { } export async function refresh() { + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return const file = Bun.file(filepath) log.info("refreshing", { file, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3fae46280..b9776dced 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -411,6 +411,7 @@ export namespace Provider { status: z.enum(["alpha", "beta", "deprecated", "active"]), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()), + release_date: z.string(), }) .meta({ ref: "Model", @@ -489,6 +490,7 @@ export namespace Provider { }, interleaved: model.interleaved ?? false, }, + release_date: model.release_date, } } @@ -621,6 +623,8 @@ export namespace Provider { output: model.limit?.output ?? existingModel?.limit?.output ?? 0, }, headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", } parsed.models[modelID] = parsedModel } @@ -877,7 +881,7 @@ export namespace Provider { return info } - export async function getLanguage(model: Model) { + export async function getLanguage(model: Model): Promise { const s = await state() const key = `${model.providerID}/${model.id}` if (s.models.has(key)) return s.models.get(key)! diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1c4fa39c8..9af5589e8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -171,6 +171,20 @@ export namespace ProviderTransform { const filtered = msg.content.map((part) => { if (part.type !== "file" && part.type !== "image") return part + // Check for empty base64 image data + if (part.type === "image") { + const imageStr = part.image.toString() + if (imageStr.startsWith("data:")) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + if (match && (!match[2] || match[2].length === 0)) { + return { + type: "text" as const, + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + } + } + } + } + const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType const filename = part.type === "file" ? part.filename : undefined const modality = mimeToModality(mime) @@ -199,14 +213,29 @@ export namespace ProviderTransform { } export function temperature(model: Provider.Model) { - if (model.api.id.toLowerCase().includes("qwen")) return 0.55 - if (model.api.id.toLowerCase().includes("claude")) return undefined - if (model.api.id.toLowerCase().includes("gemini-3-pro")) return 1.0 - return 0 + const id = model.id.toLowerCase() + if (id.includes("qwen")) return 0.55 + if (id.includes("claude")) return undefined + if (id.includes("gemini-3-pro")) return 1.0 + if (id.includes("glm-4.6")) return 1.0 + if (id.includes("minimax-m2")) return 1.0 + // if (id.includes("kimi-k2")) { + // if (id.includes("thinking")) return 1.0 + // return 0.6 + // } + return undefined } export function topP(model: Provider.Model) { - if (model.api.id.toLowerCase().includes("qwen")) return 1 + const id = model.id.toLowerCase() + if (id.includes("qwen")) return 1 + if (id.includes("minimax-m2")) return 0.95 + return undefined + } + + export function topK(model: Provider.Model) { + const id = model.id.toLowerCase() + if (id.includes("minimax-m2")) return 40 return undefined } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f9d1b1c04..f8ed149ba 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,22 +1,18 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { wrapLanguageModel, type ModelMessage } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" -import { SystemPrompt } from "./system" import z from "zod" import { SessionPrompt } from "./prompt" import { Flag } from "../flag/flag" import { Token } from "../util/token" -import { Config } from "../config/config" import { Log } from "../util/log" -import { ProviderTransform } from "@/provider/transform" import { SessionProcessor } from "./processor" import { fn } from "@/util/fn" -import { mergeDeep, pipe } from "remeda" +import { Agent } from "@/agent/agent" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -90,24 +86,21 @@ export namespace SessionCompaction { parentID: string messages: MessageV2.WithParts[] sessionID: string - model: { - providerID: string - modelID: string - } - agent: string abort: AbortSignal auto: boolean }) { - const cfg = await Config.get() - const model = await Provider.getModel(input.model.providerID, input.model.modelID) - const language = await Provider.getLanguage(model) - const system = [...SystemPrompt.compaction(model.providerID)] + const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const agent = await Agent.get("compaction") + const model = agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, - mode: input.agent, + mode: "compaction", + agent: "compaction", summary: true, path: { cwd: Instance.directory, @@ -120,7 +113,7 @@ export namespace SessionCompaction { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: input.model.modelID, + modelID: model.id, providerID: model.providerID, time: { created: Date.now(), @@ -129,46 +122,18 @@ export namespace SessionCompaction { const processor = SessionProcessor.create({ assistantMessage: msg, sessionID: input.sessionID, - model: model, + model, abort: input.abort, }) const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - // set to 0, we handle loop - maxRetries: 0, - providerOptions: ProviderTransform.providerOptions( - model, - pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)), - ), - headers: model.headers, - abortSignal: input.abort, - tools: model.capabilities.toolcall ? {} : undefined, + user: userMessage, + agent, + abort: input.abort, + sessionID: input.sessionID, + tools: {}, + system: [], messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - input.messages.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - - return false - }), - ), + ...MessageV2.toModelMessage(input.messages), { role: "user", content: [ @@ -179,28 +144,9 @@ export namespace SessionCompaction { ], }, ], - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, - }, + model, }) + if (result === "continue" && input.auto) { const continueMsg = await Session.updateMessage({ id: Identifier.ascending("message"), @@ -209,8 +155,8 @@ export namespace SessionCompaction { time: { created: Date.now(), }, - agent: input.agent, - model: input.model, + agent: userMessage.agent, + model: userMessage.model, }) await Session.updatePart({ id: Identifier.ascending("part"), diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bf3135284..b1a193904 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -234,22 +234,12 @@ export namespace Session { }) export const unshare = fn(Identifier.schema("session"), async (id) => { - const cfg = await Config.get() - if (cfg.enterprise?.url) { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - await update(id, (draft) => { - draft.share = undefined - }) - } - const share = await getShare(id) - if (!share) return - await Storage.remove(["share", id]) + // Use ShareNext to remove the share (same as share function uses ShareNext to create) + const { ShareNext } = await import("@/share/share-next") + await ShareNext.remove(id) await update(id, (draft) => { draft.share = undefined }) - const { Share } = await import("../share/share") - await Share.remove(id, share.secret) }) export async function update(id: string, editor: (session: Info) => void) { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts new file mode 100644 index 000000000..565d037f4 --- /dev/null +++ b/packages/opencode/src/session/llm.ts @@ -0,0 +1,190 @@ +import { Provider } from "@/provider/provider" +import { Log } from "@/util/log" +import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" +import { clone, mergeDeep, pipe } from "remeda" +import { ProviderTransform } from "@/provider/transform" +import { Config } from "@/config/config" +import { Instance } from "@/project/instance" +import type { Agent } from "@/agent/agent" +import type { MessageV2 } from "./message-v2" +import { Plugin } from "@/plugin" +import { SystemPrompt } from "./system" +import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@/flag/flag" + +export namespace LLM { + const log = Log.create({ service: "llm" }) + + export const OUTPUT_TOKEN_MAX = 32_000 + + export type StreamInput = { + user: MessageV2.User + sessionID: string + model: Provider.Model + agent: Agent.Info + system: string[] + abort: AbortSignal + messages: ModelMessage[] + small?: boolean + tools: Record + retries?: number + } + + export type StreamOutput = StreamTextResult + + export async function stream(input: StreamInput) { + const l = log + .clone() + .tag("providerID", input.model.providerID) + .tag("modelID", input.model.id) + .tag("sessionID", input.sessionID) + .tag("small", (input.small ?? false).toString()) + .tag("agent", input.agent.name) + l.info("stream", { + modelID: input.model.id, + providerID: input.model.providerID, + }) + const [language, cfg] = await Promise.all([Provider.getLanguage(input.model), Config.get()]) + + const system = SystemPrompt.header(input.model.providerID) + system.push( + [ + // use agent prompt otherwise provider prompt + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // any custom prompt passed into this call + ...input.system, + // any custom prompt from last user message + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + + const original = clone(system) + await Plugin.trigger("experimental.chat.system.transform", {}, { system }) + if (system.length === 0) { + system.push(...original) + } + + const params = await Plugin.trigger( + "chat.params", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + provider: Provider.getProvider(input.model.providerID), + message: input.user, + }, + { + temperature: input.model.capabilities.temperature + ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) + : undefined, + topP: input.agent.topP ?? ProviderTransform.topP(input.model), + options: pipe( + {}, + mergeDeep(ProviderTransform.options(input.model, input.sessionID)), + input.small ? mergeDeep(ProviderTransform.smallOptions(input.model)) : mergeDeep({}), + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + ), + }, + ) + + l.info("params", { + params, + }) + + const maxOutputTokens = ProviderTransform.maxOutputTokens( + input.model.api.npm, + params.options, + input.model.limit.output, + OUTPUT_TOKEN_MAX, + ) + + const tools = await resolveTools(input) + + return streamText({ + onError(error) { + l.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && tools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + }, + temperature: params.temperature, + topP: params.topP, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + maxOutputTokens, + abortSignal: input.abort, + headers: { + ...(input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : undefined), + ...input.model.headers, + }, + maxRetries: input.retries ?? 0, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ], + model: wrapLanguageModel({ + model: language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, + }) + } + + async function resolveTools(input: Pick) { + const enabled = pipe( + input.agent.tools, + mergeDeep(await ToolRegistry.enabled(input.agent)), + mergeDeep(input.user.tools ?? {}), + ) + for (const [key, value] of Object.entries(enabled)) { + if (value === false) delete input.tools[key] + } + return input.tools + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1f4fffaa6..76162c797 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -348,7 +348,11 @@ export namespace MessageV2 { parentID: z.string(), modelID: z.string(), providerID: z.string(), + /** + * @deprecated + */ mode: z.string(), + agent: z.string(), path: z.object({ cwd: z.string(), root: z.string(), @@ -412,12 +416,7 @@ export namespace MessageV2 { }) export type WithParts = z.infer - export function toModelMessage( - input: { - info: Info - parts: Part[] - }[], - ): ModelMessage[] { + export function toModelMessage(input: WithParts[]): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { @@ -461,6 +460,15 @@ export namespace MessageV2 { } if (msg.info.role === "assistant") { + if ( + msg.info.error && + !( + MessageV2.AbortedError.isInstance(msg.info.error) && + msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) + ) { + continue + } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f1f7dd096..1d4d24303 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,5 +1,4 @@ import { MessageV2 } from "./message-v2" -import { streamText } from "ai" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -12,6 +11,8 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { LLM } from "./llm" +import { Config } from "@/config/config" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -20,15 +21,6 @@ export namespace SessionProcessor { export type Info = Awaited> export type Result = Awaited> - export type StreamInput = Parameters[0] - - export type TBD = { - model: { - modelID: string - providerID: string - } - } - export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string @@ -47,13 +39,14 @@ export namespace SessionProcessor { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(streamInput: StreamInput) { + async process(streamInput: LLM.StreamInput) { log.info("process") + const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = streamText(streamInput) + const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted() @@ -228,7 +221,7 @@ export namespace SessionProcessor { }) if (value.error instanceof Permission.RejectedError) { - blocked = true + blocked = shouldBreak } delete toolcalls[value.toolCallId] } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c9e24f8ca..e71162d0b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,27 +5,17 @@ import z from "zod" import { Identifier } from "../id/id" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" -import { Flag } from "../flag/flag" import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" -import { - generateText, - type ModelMessage, - type Tool as AITool, - tool, - wrapLanguageModel, - stepCountIs, - jsonSchema, -} from "ai" +import { type Tool as AITool, tool, jsonSchema } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" - import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" @@ -44,12 +34,13 @@ import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" -import { Config } from "../config/config" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +import { LLM } from "./llm" +import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" // @ts-ignore @@ -96,8 +87,8 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), - system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + system: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -145,6 +136,20 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer + export const prompt = fn(PromptInput, async (input) => { + const session = await Session.get(input.sessionID) + await SessionRevert.cleanup(session) + + const message = await createUserMessage(input) + await Session.touch(input.sessionID) + + if (input.noReply === true) { + return message + } + + return loop(input.sessionID) + }) + export async function resolvePromptParts(template: string): Promise { const parts: PromptInput["parts"] = [ { @@ -196,20 +201,6 @@ export namespace SessionPrompt { return parts } - export const prompt = fn(PromptInput, async (input) => { - const session = await Session.get(input.sessionID) - await SessionRevert.cleanup(session) - - const message = await createUserMessage(input) - await Session.touch(input.sessionID) - - if (input.noReply === true) { - return message - } - - return loop(input.sessionID) - }) - function start(sessionID: string) { const s = state() if (s[sessionID]) return @@ -291,7 +282,6 @@ export namespace SessionPrompt { }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) - const language = await Provider.getLanguage(model) const task = tasks.pop() // pending subtask @@ -304,6 +294,7 @@ export namespace SessionPrompt { parentID: lastUser.id, sessionID, mode: task.agent, + agent: task.agent, path: { cwd: Instance.directory, root: Instance.worktree, @@ -414,11 +405,6 @@ export namespace SessionPrompt { messages: msgs, parentID: lastUser.id, abort, - agent: lastUser.agent, - model: { - providerID: model.providerID, - modelID: model.id, - }, sessionID, auto: task.auto, }) @@ -442,7 +428,6 @@ export namespace SessionPrompt { } // normal processing - const cfg = await Config.get() const agent = await Agent.get(lastUser.agent) const maxSteps = agent.maxSteps ?? Infinity const isLastStep = step >= maxSteps @@ -450,12 +435,14 @@ export namespace SessionPrompt { messages: msgs, agent, }) + const processor = SessionProcessor.create({ assistantMessage: (await Session.updateMessage({ id: Identifier.ascending("message"), parentID: lastUser.id, role: "assistant", mode: agent.name, + agent: agent.name, path: { cwd: Instance.directory, root: Instance.worktree, @@ -478,12 +465,6 @@ export namespace SessionPrompt { model, abort, }) - const system = await resolveSystemPrompt({ - model, - agent, - system: lastUser.system, - isLastStep, - }) const tools = await resolveTools({ agent, sessionID, @@ -491,29 +472,6 @@ export namespace SessionPrompt { tools: lastUser.tools, processor, }) - const provider = await Provider.getProvider(model.providerID) - const params = await Plugin.trigger( - "chat.params", - { - sessionID: sessionID, - agent: lastUser.agent, - model: model, - provider, - message: lastUser, - }, - { - temperature: model.capabilities.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model), - options: pipe( - {}, - mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)), - mergeDeep(model.options), - mergeDeep(agent.options), - ), - }, - ) if (step === 1) { SessionSummary.summarize({ @@ -522,134 +480,29 @@ export namespace SessionPrompt { }) } - // Deep copy message history so that modifications made by plugins do not - // affect the original messages - const sessionMessages = clone( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - return false - }), - ) + const sessionMessages = clone(msgs) await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) - const messages: ModelMessage[] = [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage(sessionMessages), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ] - const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(input) { - const lower = input.toolCall.toolName.toLowerCase() - if (lower !== input.toolCall.toolName && tools[lower]) { - log.info("repairing tool call", { - tool: input.toolCall.toolName, - repaired: lower, - }) - return { - ...input.toolCall, - toolName: lower, - } - } - return { - ...input.toolCall, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", - } - }, - headers: { - ...(model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": sessionID, - "x-opencode-request": lastUser.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : undefined), - ...model.headers, - }, - // set to 0, we handle loop - maxRetries: 0, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: ProviderTransform.maxOutputTokens( - model.api.npm, - params.options, - model.limit.output, - OUTPUT_TOKEN_MAX, - ), - abortSignal: abort, - providerOptions: ProviderTransform.providerOptions(model, params.options), - stopWhen: stepCountIs(1), - temperature: params.temperature, - topP: params.topP, - toolChoice: isLastStep ? "none" : undefined, - messages, - tools: model.capabilities.toolcall === false ? undefined : tools, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - prompt types are compatible at runtime - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - // Transform tool schemas for provider compatibility - if (args.params.tools && Array.isArray(args.params.tools)) { - args.params.tools = args.params.tools.map((tool: any) => { - // Tools at middleware level have inputSchema, not parameters - if (tool.inputSchema && typeof tool.inputSchema === "object") { - // Transform the inputSchema for provider compatibility - return { - ...tool, - inputSchema: ProviderTransform.schema(model, tool.inputSchema), - } - } - // If no inputSchema, return tool unchanged - return tool - }) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: sessionID, - }, - }, + user: lastUser, + agent, + abort, + sessionID, + system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + messages: [ + ...MessageV2.toModelMessage(sessionMessages), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), + ], + tools, + model, }) if (result === "stop") break continue @@ -673,33 +526,6 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { - system?: string - agent: Agent.Info - model: Provider.Model - isLastStep?: boolean - }) { - let system = SystemPrompt.header(input.model.providerID) - system.push( - ...(() => { - if (input.system) return [input.system] - if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.model) - })(), - ) - system.push(...(await SystemPrompt.environment())) - system.push(...(await SystemPrompt.custom())) - - if (input.isLastStep) { - system.push(MAX_STEPS) - } - - // max 2 system prompt messages for caching purposes - const [first, ...rest] = system - system = [first, rest.join("\n")] - return system - } - async function resolveTools(input: { agent: Agent.Info model: Provider.Model @@ -707,6 +533,7 @@ export namespace SessionPrompt { tools?: Record processor: SessionProcessor.Info }) { + using _ = log.time("resolveTools") const tools: Record = {} const enabledTools = pipe( input.agent.tools, @@ -776,7 +603,6 @@ export namespace SessionPrompt { }, }) } - for (const [key, item] of Object.entries(await MCP.tools())) { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute @@ -855,7 +681,6 @@ export namespace SessionPrompt { created: Date.now(), }, tools: input.tools, - system: input.system, agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), } @@ -1146,7 +971,7 @@ export namespace SessionPrompt { synthetic: true, }) } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan") + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") if (wasPlan && input.agent.name === "build") { userMessage.parts.push({ id: Identifier.ascending("part"), @@ -1214,6 +1039,7 @@ export namespace SessionPrompt { sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, + agent: input.agent, cost: 0, path: { cwd: Instance.directory, @@ -1508,28 +1334,24 @@ export namespace SessionPrompt { input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) .length === 1 if (!isFirst) return - const cfg = await Config.get() - const small = - (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) - const language = await Provider.getLanguage(small) - const provider = await Provider.getProvider(small.providerID) - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) - await generateText({ - // use higher # for reasoning models since reasoning tokens eat up a lot of the budget - maxOutputTokens: small.capabilities.reasoning ? 3000 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + if (!agent) return + const result = await LLM.stream({ + agent, + user: input.message.info as MessageV2.User, + system: [], + small: true, + tools: {}, + model: await iife(async () => { + if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID) + return ( + (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + ) + }), + abort: new AbortController().signal, + sessionID: input.session.id, + retries: 2, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user", content: "Generate a title for this conversation:\n", @@ -1553,32 +1375,19 @@ export namespace SessionPrompt { }, ]), ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.session.id, - }, - }, }) - .then((result) => { - if (result.text) - return Session.update(input.session.id, (draft) => { - const cleaned = result.text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return + const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) + if (text) + return Session.update(input.session.id, (draft) => { + const cleaned = text + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!cleaned) return - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - draft.title = title - }) - }) - .catch((error) => { - log.error("failed to generate title", { error, model: small.id }) + const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + draft.title = title }) } } diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 3cabacdb8..dcf573a6c 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -68,6 +68,15 @@ export namespace SessionRetry { if (json.code === "Some resource has been exhausted") { return "Provider is overloaded" } + if (json.type === "error" && json.error?.code?.includes("rate_limit")) { + return "Rate Limited" + } + if ( + json.error?.message?.includes("no_kv_space") || + (json.type === "error" && json.error?.type === "server_error") + ) { + return "Provider Server Error" + } } catch {} } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 4761c9d2f..83519307a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,20 +1,21 @@ import { Provider } from "@/provider/provider" -import { Config } from "@/config/config" + import { fn } from "@/util/fn" import z from "zod" import { Session } from "." -import { generateText, type ModelMessage } from "ai" + import { MessageV2 } from "./message-v2" import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" -import { ProviderTransform } from "@/provider/transform" -import { SystemPrompt } from "./system" + import { Log } from "@/util/log" import path from "path" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" -import { mergeDeep, pipe } from "remeda" + +import { LLM } from "./llm" +import { Agent } from "@/agent/agent" export namespace SessionSummary { const log = Log.create({ service: "session.summary" }) @@ -61,7 +62,6 @@ export namespace SessionSummary { } async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) { - const cfg = await Config.get() const messages = input.messages.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) @@ -78,27 +78,17 @@ export namespace SessionSummary { const small = (await Provider.getSmallModel(assistantMsg.providerID)) ?? (await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID)) - const language = await Provider.getLanguage(small) - - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, assistantMsg.sessionID)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart if (textPart && !userMsg.summary?.title) { - const result = await generateText({ - maxOutputTokens: small.capabilities.reasoning ? 1500 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + const stream = await LLM.stream({ + agent, + user: userMsg, + tools: {}, + model: agent.model ? await Provider.getModel(agent.model.providerID, agent.model.modelID) : small, + small: true, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user" as const, content: ` @@ -109,18 +99,14 @@ export namespace SessionSummary { `, }, ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, }) - log.info("title", { title: result.text }) - userMsg.summary.title = result.text + const result = await stream.text + log.info("title", { title: result }) + userMsg.summary.title = result await Session.updateMessage(userMsg) } @@ -138,34 +124,30 @@ export namespace SessionSummary { } } } - const result = await generateText({ - model: language, - maxOutputTokens: 100, - providerOptions: ProviderTransform.providerOptions(small, options), + const summaryAgent = await Agent.get("summary") + const stream = await LLM.stream({ + agent: summaryAgent, + user: userMsg, + tools: {}, + model: summaryAgent.model + ? await Provider.getModel(summaryAgent.model.providerID, summaryAgent.model.modelID) + : small, + small: true, messages: [ - ...SystemPrompt.summarize(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), ...MessageV2.toModelMessage(messages), { - role: "user", + role: "user" as const, content: `Summarize the above conversation according to your system prompts.`, }, ], - headers: small.headers, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, - }).catch(() => {}) + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, + }) + const result = await stream.text if (result) { - userMsg.summary.body = result.text + userMsg.summary.body = result } } await Session.updateMessage(userMsg) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 3146110cf..e15185b38 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -14,8 +14,7 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" -import PROMPT_SUMMARIZE from "./prompt/summarize.txt" -import PROMPT_TITLE from "./prompt/title.txt" + import PROMPT_CODEX from "./prompt/codex.txt" import type { Provider } from "@/provider/provider" @@ -118,31 +117,4 @@ export namespace SystemPrompt { ) return Promise.all(found).then((result) => result.filter(Boolean)) } - - export function compaction(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_COMPACTION] - default: - return [PROMPT_COMPACTION] - } - } - - export function summarize(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE] - default: - return [PROMPT_SUMMARIZE] - } - } - - export function title(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE] - default: - return [PROMPT_TITLE] - } - } } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index fea9c3bb9..37ecdf7ea 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -157,7 +157,7 @@ export namespace ShareNext { secret: share.secret, }), }) - await Storage.remove(["session_share", share.id]) + await Storage.remove(["session_share", sessionID]) } async function fullSync(sessionID: string) { diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6b84d1bff..115d8f8b2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -50,7 +50,6 @@ const parser = lazy(async () => { }) // TODO: we may wanna rename this tool so it works better on other shells - export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() log.info("bash tool using shell", { shell }) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index a5d34c949..fdf115ac4 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,8 @@ import { Instance } from "../project/instance" import { Agent } from "../agent/agent" import { Snapshot } from "@/snapshot" +const MAX_DIAGNOSTICS_PER_FILE = 20 + function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") } @@ -74,7 +76,7 @@ export const EditTool = Tool.define("edit", { let diff = "" let contentOld = "" let contentNew = "" - await (async () => { + await FileTime.withLock(filePath, async () => { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -95,6 +97,7 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) + FileTime.read(ctx.sessionID, filePath) return } @@ -131,9 +134,8 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) - })() - - FileTime.read(ctx.sessionID, filePath) + FileTime.read(ctx.sessionID, filePath) + }) let output = "" await LSP.touchFile(filePath, true) @@ -141,10 +143,11 @@ export const EditTool = Tool.define("edit", { for (const [file, issues] of Object.entries(diagnostics)) { if (issues.length === 0) continue if (file === filePath) { - output += `\nThis file has errors, please fix\n\n${issues - .filter((item) => item.severity === 1) - .map(LSP.Diagnostic.pretty) - .join("\n")}\n\n` + const errors = issues.filter((item) => item.severity === 1) + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` continue } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7e440a78a..647c74267 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,8 +21,11 @@ import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" export namespace ToolRegistry { + const log = Log.create({ service: "tool.registry" }) + export const state = Instance.state(async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("tool/*.{js,ts}") @@ -119,10 +122,13 @@ export namespace ToolRegistry { } return true }) - .map(async (t) => ({ - id: t.id, - ...(await t.init()), - })), + .map(async (t) => { + using _ = log.time(t.id) + return { + id: t.id, + ...(await t.init()), + } + }), ) return result } diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 7b109261e..03f2ba891 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -11,6 +11,9 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Agent } from "../agent/agent" +const MAX_DIAGNOSTICS_PER_FILE = 20 +const MAX_PROJECT_DIAGNOSTICS_FILES = 5 + export const WriteTool = Tool.define("write", { description: DESCRIPTION, parameters: z.object({ @@ -77,13 +80,20 @@ export const WriteTool = Tool.define("write", { let output = "" await LSP.touchFile(filepath, true) const diagnostics = await LSP.diagnostics() + let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { if (issues.length === 0) continue + const sorted = issues.toSorted((a, b) => (a.severity ?? 4) - (b.severity ?? 4)) + const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" if (file === filepath) { - output += `\nThis file has errors, please fix\n\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + output += `\nThis file has errors, please fix\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` continue } - output += `\n\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n\n` + if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue + projectDiagnosticsCount++ + output += `\n\n${file}\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n\n` } return { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index c2a294ab2..08316a23f 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -11,6 +11,18 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Pre-fetch models.json so tests don't need the macro fallback +// Also write the cache version file to prevent global/index.ts from clearing the cache +const cacheDir = path.join(dir, "cache", "opencode") +await fs.mkdir(cacheDir, { recursive: true }) +await fs.writeFile(path.join(cacheDir, "version"), "14") +const response = await fetch("https://models.dev/api.json") +if (response.ok) { + await fs.writeFile(path.join(cacheDir, "models.json"), await response.text()) +} +// Disable models.dev refresh to avoid race conditions during tests +process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true" + // Clear provider env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4e202a63c..b040f24f4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -144,6 +144,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result).toHaveLength(1) @@ -204,6 +205,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Thinking...") @@ -250,6 +252,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].content).toEqual([ @@ -259,3 +262,106 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +describe("ProviderTransform.message - empty image handling", () => { + const mockModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("should replace empty base64 image with error text", () => { + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: "data:image/png;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) + + test("should keep valid base64 images unchanged", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + }) + + test("should handle mixed valid and empty images", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + { type: "image", image: "data:image/jpeg;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "text", text: "Compare these images" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + expect(result[0].content[2]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8589fa250..4a7841908 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.152", + "version": "1.0.153", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 57ca75d60..9dd4820b9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -185,6 +185,12 @@ export interface Hooks { }[] }, ) => Promise + "experimental.chat.system.transform"?: ( + input: {}, + output: { + system: string[] + }, + ) => Promise "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e6e888b94..a18040830 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.152", + "version": "1.0.153", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 90df76c22..16fe07ae4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1203,10 +1203,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1222,8 +1222,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, @@ -1289,10 +1289,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1308,8 +1308,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9d0bbcc92..c466e78dc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -147,6 +147,7 @@ export type AssistantMessage = { modelID: string providerID: string mode: string + agent: string path: { cwd: string root: string @@ -475,6 +476,40 @@ export type EventPermissionReplied = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string + /** + * Unique identifier for the todo item + */ + id: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + export type SessionStatus = | { type: "idle" @@ -511,40 +546,6 @@ export type EventSessionCompacted = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventCommandExecuted = { type: "command.executed" properties: { @@ -745,11 +746,11 @@ export type Event = | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied + | EventFileEdited + | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventFileEdited - | EventTodoUpdated | EventCommandExecuted | EventSessionCreated | EventSessionUpdated @@ -1518,6 +1519,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Continue the agent loop when a tool call is denied + */ + continue_loop_on_deny?: boolean } } @@ -1657,6 +1662,7 @@ export type Model = { headers: { [key: string]: string } + release_date: string } export type Provider = { @@ -1734,7 +1740,8 @@ export type Agent = { name: string description?: string mode: "subagent" | "primary" | "all" - builtIn: boolean + native?: boolean + hidden?: boolean topP?: number temperature?: number color?: string @@ -2797,10 +2804,10 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { @@ -2892,10 +2899,10 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 98c8b3586..71f1df312 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1997,9 +1997,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2009,6 +2006,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -2202,9 +2202,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2214,6 +2211,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -5193,6 +5193,9 @@ "mode": { "type": "string" }, + "agent": { + "type": "string" + }, "path": { "type": "object", "properties": { @@ -5251,6 +5254,7 @@ "modelID", "providerID", "mode", + "agent", "path", "cost", "tokens" @@ -6152,6 +6156,72 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": ["content", "status", "priority", "id"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, "SessionStatus": { "anyOf": [ { @@ -6255,72 +6325,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - }, - "id": { - "description": "Unique identifier for the todo item", - "type": "string" - } - }, - "required": ["content", "status", "priority", "id"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, "Event.command.executed": { "type": "object", "properties": { @@ -6886,6 +6890,12 @@ { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, { "$ref": "#/components/schemas/Event.session.status" }, @@ -6895,12 +6905,6 @@ { "$ref": "#/components/schemas/Event.session.compacted" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, { "$ref": "#/components/schemas/Event.command.executed" }, @@ -8279,6 +8283,10 @@ "items": { "type": "string" } + }, + "continue_loop_on_deny": { + "description": "Continue the agent loop when a tool call is denied", + "type": "boolean" } } } @@ -8673,9 +8681,24 @@ "additionalProperties": { "type": "string" } + }, + "release_date": { + "type": "string" } }, - "required": ["id", "providerID", "api", "name", "capabilities", "cost", "limit", "status", "options", "headers"] + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ] }, "Provider": { "type": "object", @@ -8916,7 +8939,10 @@ "type": "string", "enum": ["subagent", "primary", "all"] }, - "builtIn": { + "native": { + "type": "boolean" + }, + "hidden": { "type": "boolean" }, "topP": { @@ -8997,7 +9023,7 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "builtIn", "permission", "tools", "options"] + "required": ["name", "mode", "permission", "tools", "options"] }, "MCPStatusConnected": { "type": "object", diff --git a/packages/slack/package.json b/packages/slack/package.json index 925602f9a..7a5c339e8 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.152", + "version": "1.0.153", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 768791ddc..22fa35023 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.152", + "version": "1.0.153", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/tauri/scripts/utils.ts b/packages/tauri/scripts/utils.ts index b2885d00a..3e74346c8 100644 --- a/packages/tauri/scripts/utils.ts +++ b/packages/tauri/scripts/utils.ts @@ -36,7 +36,7 @@ export function getCurrentSidecar(target = RUST_TARGET) { export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) { await $`mkdir -p src-tauri/sidecars` - const dest = `src-tauri/sidecars/opencode-${target}${process.platform === "win32" ? ".exe" : ""}` + const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}` await $`cp ${source} ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index a275fab78..b06ccd06c 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -4,9 +4,9 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; +use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; @@ -66,7 +66,7 @@ fn find_and_kill_process_on_port(port: u16) -> Result<(), Box CommandChild { let (mut rx, child) = app .shell() - .sidecar("opencode") + .sidecar("opencode-cli") .unwrap() .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_CLIENT", "desktop") diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 94ac84c64..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode", - "mainBinaryName": "OpenCode Desktop", + "mainBinaryName": "OpenCode", "version": "../package.json", "identifier": "ai.opencode.desktop", "build": { @@ -21,7 +21,7 @@ "active": true, "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], - "externalBin": ["sidecars/opencode"], + "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, "macOS": { "entitlements": "./entitlements.plist" @@ -30,7 +30,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK", - "endpoints": ["https://github.com/brendonovich/opencode/releases/latest/download/latest.json"] + "endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"] } } } diff --git a/packages/ui/package.json b/packages/ui/package.json index d3d230c49..2632f6961 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.152", + "version": "1.0.153", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 4fab331a5..8fae33694 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -13,7 +13,9 @@ export type TriggerTitle = { } const isTriggerTitle = (val: any): val is TriggerTitle => { - return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node) + return ( + typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node)) + ) } export interface BasicToolProps { diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index c5bd2c696..800795e87 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,6 +9,7 @@ user-select: none; cursor: default; outline: none; + white-space: nowrap; &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -148,7 +149,7 @@ padding: 0 12px 0 8px; } - gap: 4px; + gap: 8px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 979906e26..6fa71c64c 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -59,9 +59,8 @@ [data-slot="dialog-header"] { display: flex; - /* height: 40px; */ - /* padding: 4px 4px 4px 8px; */ - padding: 20px; + padding: 16px; + padding-left: 20px; justify-content: space-between; align-items: center; flex-shrink: 0; @@ -80,7 +79,29 @@ } /* [data-slot="dialog-close-button"] {} */ } - /* [data-slot="dialog-description"] {} */ + + [data-slot="dialog-description"] { + display: flex; + padding: 16px; + padding-left: 20px; + padding-top: 0; + margin-top: -8px; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + align-self: stretch; + + color: var(--text-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + [data-slot="dialog-body"] { width: 100%; position: relative; diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 56053278d..47d6af42e 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,96 +1,45 @@ -import { - Dialog as Kobalte, - DialogRootProps, - DialogTitleProps, - DialogCloseButtonProps, - DialogDescriptionProps, -} from "@kobalte/core/dialog" -import { ComponentProps, type JSX, onCleanup, onMount, Show, splitProps } from "solid-js" +import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" import { IconButton } from "./icon-button" -export interface DialogProps extends DialogRootProps { - trigger?: JSX.Element +export interface DialogProps extends ParentProps { + title?: JSXElement + description?: JSXElement + action?: JSXElement class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -export function DialogRoot(props: DialogProps) { - let trigger!: HTMLElement - const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) - - const resetTabIndex = () => { - trigger.tabIndex = 0 - } - - const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => { - const firstChild = e.currentTarget?.firstElementChild as HTMLElement - if (!firstChild) return - - firstChild.focus() - trigger.tabIndex = -1 - - firstChild.addEventListener("focusout", resetTabIndex) - onCleanup(() => { - firstChild.removeEventListener("focusout", resetTabIndex) - }) - } - - onMount(() => { - // @ts-ignore - document?.activeElement?.blur?.() - }) - +export function Dialog(props: DialogProps) { return ( - - - - {props.trigger} - - - - -
-
- - {local.children} - -
-
-
-
+
+
+ + +
+ + {props.title} + + + {props.action} + + + + +
+
+ + {props.description} + +
{props.children}
+
+
+
) } - -function DialogHeader(props: ComponentProps<"div">) { - return
-} - -function DialogBody(props: ComponentProps<"div">) { - return
-} - -function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) { - return -} - -function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) { - return -} - -function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) { - return -} - -export const Dialog = Object.assign(DialogRoot, { - Header: DialogHeader, - Title: DialogTitle, - Description: DialogDescription, - CloseButton: DialogCloseButton, - Body: DialogBody, -}) diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 132824164..cd9e73d1d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -2,6 +2,43 @@ display: flex; flex-direction: column; gap: 20px; + overflow: hidden; + + [data-slot="list-search"] { + display: flex; + height: 40px; + flex-shrink: 0; + padding: 4px 10px 4px 16px; + align-items: center; + gap: 12px; + align-self: stretch; + + border-radius: var(--radius-md); + background: var(--surface-base); + + [data-slot="list-search-container"] { + display: flex; + align-items: center; + gap: 16px; + flex: 1 0 0; + + [data-slot="list-search-input"] { + width: 100%; + } + } + } + + [data-slot="list-scroll"] { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + } [data-slot="list-empty-state"] { display: flex; @@ -41,6 +78,7 @@ [data-slot="list-header"] { display: flex; + z-index: 10; height: 28px; padding: 0 10px; justify-content: space-between; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 013767e60..7ec6e159d 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,7 +1,14 @@ -import { createEffect, Show, For, type JSX, createSignal } from "solid-js" +import { createEffect, on, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" +import { IconButton } from "./icon-button" +import { TextField } from "./text-field" + +export interface ListSearchProps { + placeholder?: string + autofocus?: boolean +} export interface ListProps extends FilteredListProps { class?: string @@ -10,6 +17,7 @@ export interface ListProps extends FilteredListProps { onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void activeIcon?: IconProps["name"] filter?: string + search?: ListSearchProps | boolean } export interface ListRef { @@ -19,30 +27,39 @@ export interface ListRef { export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { const [scrollRef, setScrollRef] = createSignal(undefined) + const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList({ - items: props.items, - key: props.key, - filterKeys: props.filterKeys, - current: props.current, - groupBy: props.groupBy, - sortBy: props.sortBy, - sortGroupsBy: props.sortGroupsBy, - }) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) + + const searchProps = () => (typeof props.search === "object" ? props.search : {}) createEffect(() => { - if (props.filter === undefined) return - onInput(props.filter) + if (props.filter !== undefined) { + onInput(props.filter) + } }) - createEffect(() => { - filter() - scrollRef()?.scrollTo(0, 0) - reset() - }) + createEffect((prev) => { + if (!props.search) return + const current = internalFilter() + if (prev !== current) { + onInput(current) + } + return current + }, "") + + createEffect( + on( + filter, + () => { + scrollRef()?.scrollTo(0, 0) + }, + { defer: true }, + ), + ) createEffect(() => { if (!scrollRef()) return @@ -92,52 +109,78 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
- 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for "{filter()}" -
+
+ +
+
+ +
- } - > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item, i) => ( - - )} - + + setInternalFilter("")} /> + +
+ +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}"
- )} - -
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item, i) => ( + + )} + +
+
+ )} +
+ +
) } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index a596b811e..f00c43bd8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -321,7 +321,6 @@ ToolRegistry.register({ render(props) { return ( - extends Omit, "filter">, - Pick { - title: string - placeholder?: string - actions?: JSX.Element -} - -export function SelectDialog(props: SelectDialogProps) { - const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) - let closeButton!: HTMLButtonElement - let inputRef: HTMLInputElement | undefined - const [filter, setFilter] = createSignal("") - let listRef: ListRef | undefined - - createEffect(() => { - if (!props.current) return - const key = props.key(props.current) - requestAnimationFrame(() => { - const element = document.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "center" }) - }) - }) - - const handleSelect = (item: T | undefined, index: number) => { - others.onSelect?.(item, index) - closeButton.click() - } - - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - const handleOpenChange = (open: boolean) => { - if (!open) setFilter("") - props.onOpenChange?.(open) - } - - return ( - - - {others.title} - {others.actions} - - -
-
-
- - -
- - setFilter("")} /> - -
- - { - listRef = ref - }} - items={others.items} - key={others.key} - filterKeys={others.filterKeys} - current={others.current} - groupBy={others.groupBy} - sortBy={others.sortBy} - sortGroupsBy={others.sortGroupsBy} - emptyMessage={others.emptyMessage} - activeIcon={others.activeIcon} - filter={filter()} - onSelect={handleSelect} - onKeyEvent={others.onKeyEvent} - > - {others.children} - - -
-
- ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index c4dd2b839..0f218b515 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,5 +1,6 @@ [data-component="session-turn"] { /* flex: 1; */ + --scroll-y: 0px; height: 100%; min-height: 0; min-width: 0; @@ -26,18 +27,26 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 32px; + gap: clamp(8px, calc(42px - var(--scroll-y) * 0.48), 42px); + overflow-anchor: none; } - [data-slot="session-turn-sticky-header"] { + [data-slot="session-turn-sticky-title"] { width: 100%; position: sticky; top: 0; background-color: var(--background-stronger); + z-index: 21; + } + + [data-slot="session-turn-response-trigger"] { + position: sticky; + top: 32px; + background-color: var(--background-stronger); z-index: 20; - display: flex; - flex-direction: column; - gap: 8px; + width: calc(100% + 9px); + margin-left: -9px; + padding-left: 9px; padding-bottom: 8px; } @@ -49,13 +58,8 @@ height: 32px; } - /* [data-slot="session-turn-message-content"] { */ - /* } */ - - [data-slot="session-turn-response-trigger"] { - width: calc(100% + 9px); - margin-left: -9px; - padding-left: 9px; + [data-slot="session-turn-message-content"] { + margin-top: -24px; } [data-slot="session-turn-message-title"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 708ac5b83..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,18 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, -} from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -60,45 +49,71 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [contentRef, setContentRef] = createSignal() - const [stickyHeaderRef, setStickyHeaderRef] = createSignal() - const [userScrolled, setUserScrolled] = createSignal(false) - const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) + const [state, setState] = createStore({ + stickyTitleRef: undefined as HTMLDivElement | undefined, + stickyTriggerRef: undefined as HTMLDivElement | undefined, + userScrolled: false, + stickyHeaderHeight: 0, + scrollY: 0, + autoScrolling: false, + }) function handleScroll() { if (!scrollRef) return + // prevents scroll loops + if (working() && scrollRef.scrollTop < 100) return + setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setUserScrolled(true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setUserScrolled(true) + setState("userScrolled", true) } } + function scrollToBottom() { + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) + requestAnimationFrame(() => { + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) + requestAnimationFrame(() => { + setState("autoScrolling", false) + }) + }) + } + createEffect(() => { if (!working()) { - setUserScrolled(false) + setState("userScrolled", false) } }) - createResizeObserver(contentRef, () => { - if (!scrollRef || userScrolled() || !working()) return - scrollRef.scrollTop = scrollRef.scrollHeight - }) + createResizeObserver( + () => state.stickyTitleRef, + ({ height }) => { + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) + }, + ) - createResizeObserver(stickyHeaderRef, ({ height }) => { - setStickyHeaderHeight(height + 8) - }) + createResizeObserver( + () => state.stickyTriggerRef, + ({ height }) => { + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) + }, + ) return ( -
+
-
+
{(message) => { const assistantMessages = createMemo(() => { @@ -175,6 +190,9 @@ export function SessionTurn( break } } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` return "Thinking" } else if (last.type === "text") { return "Gathering thoughts" @@ -197,6 +215,11 @@ export function SessionTurn( }) } + createEffect(() => { + lastPart() + scrollToBottom() + }) + const [store, setStore] = createStore({ status: rawStatus(), stepsExpanded: true, @@ -237,7 +260,7 @@ export function SessionTurn( createEffect((prev) => { const isWorking = working() - if (prev && !isWorking && !userScrolled()) { + if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) } return isWorking @@ -248,10 +271,10 @@ export function SessionTurn( data-message={message().id} data-slot="session-turn-message-container" class={props.classes?.container} - style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} + style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }} > - {/* Sticky Header */} -
+ {/* Title (sticky) */} +
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
@@ -264,29 +287,31 @@ export function SessionTurn(
-
- -
-
- -
+
+ {/* User Message */} +
+ +
+ {/* Trigger (sticky) */} +
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> +
{/* Response */} diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css new file mode 100644 index 000000000..c01e45d5f --- /dev/null +++ b/packages/ui/src/components/switch.css @@ -0,0 +1,131 @@ +[data-component="switch"] { + display: flex; + align-items: center; + gap: 8px; + cursor: default; + + [data-slot="switch-input"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + [data-slot="switch-control"] { + display: inline-flex; + align-items: center; + width: 28px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; + border: 1px solid var(--border-weak-base); + background: var(--surface-base); + transition: + background-color 150ms, + border-color 150ms; + } + + [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + box-sizing: content-box; + + border-radius: 2px; + border: 1px solid var(--border-base); + background: var(--icon-invert-base); + + /* shadows/shadow-xs */ + box-shadow: + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); + + transform: translateX(-1px); + transition: + transform 150ms, + background-color 150ms; + } + + [data-slot="switch-label"] { + user-select: none; + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-description"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-error"] { + color: var(--text-error); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); + } + + &[data-checked] [data-slot="switch-control"] { + box-sizing: border-box; + border-color: var(--icon-strong-base); + background-color: var(--icon-strong-base); + } + + &[data-checked] [data-slot="switch-thumb"] { + border: none; + transform: translateX(12px); + background-color: var(--icon-invert-base); + } + + &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &[data-disabled] [data-slot="switch-control"] { + border-color: var(--border-disabled); + background-color: var(--surface-disabled); + } + + &[data-disabled] [data-slot="switch-thumb"] { + background-color: var(--icon-disabled); + } + + &[data-invalid] [data-slot="switch-control"] { + border-color: var(--border-error); + } + + &[data-readonly] { + cursor: default; + pointer-events: none; + } +} diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx new file mode 100644 index 000000000..af70dfb5c --- /dev/null +++ b/packages/ui/src/components/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as Kobalte } from "@kobalte/core/switch" +import { children, Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface SwitchProps extends ParentProps> { + hideLabel?: boolean + description?: string +} + +export function Switch(props: SwitchProps) { + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const resolved = children(() => local.children) + return ( + + + + + {resolved()} + + + + {local.description} + + + + + + + ) +} diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx new file mode 100644 index 000000000..af5da06f9 --- /dev/null +++ b/packages/ui/src/context/dialog.tsx @@ -0,0 +1,79 @@ +import { For, Show, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" + +type DialogElement = JSX.Element | (() => JSX.Element) + +export const { use: useDialog, provider: DialogProvider } = createSimpleContext({ + name: "Dialog", + init: () => { + const [store, setStore] = createStore({ + stack: [] as { + element: DialogElement + onClose?: () => void + }[], + }) + + return { + get stack() { + return store.stack + }, + push(element: DialogElement, onClose?: () => void) { + setStore("stack", (s) => [...s, { element, onClose }]) + }, + pop() { + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + }, + replace(element: DialogElement, onClose?: () => void) { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", [{ element, onClose }]) + }, + clear() { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", []) + }, + } + }, +}) + +import { Dialog as Kobalte } from "@kobalte/core/dialog" + +export function DialogRoot(props: { children?: JSX.Element }) { + const dialog = useDialog() + return ( + <> + {props.children} + 0}> +
+ + {(item, index) => ( + + { + if (!open) { + item.onClose?.() + dialog.pop() + } + }} + > + + + {typeof item.element === "function" ? item.element() : item.element} + + + + )} + +
+
+ + ) +} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts index 3e0f5de74..499cb74d4 100644 --- a/packages/ui/src/context/index.ts +++ b/packages/ui/src/context/index.ts @@ -1,3 +1,4 @@ export * from "./helper" export * from "./data" export * from "./diff" +export * from "./dialog" diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e3b373d4d..76a5ae84f 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps { - items: (filter: string) => T[] | Promise + items: T[] | ((filter: string) => T[] | Promise) key: (item: T) => string filterKeys?: string[] current?: T @@ -19,10 +19,13 @@ export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) const [grouped, { refetch }] = createResource( - () => store.filter, - async (filter) => { + () => ({ + filter: store.filter, + items: typeof props.items === "function" ? undefined : props.items, + }), + async ({ filter, items }) => { const needle = filter?.toLowerCase() - const all = (await props.items(needle)) || [] + const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 0ae3493eb..3480976dd 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -5,7 +5,7 @@ @keyframes pulse-opacity { 0%, 100% { - opacity: 0; + opacity: 0.4; } 50% { opacity: 1; @@ -18,7 +18,7 @@ opacity: 0; } 50% { - opacity: 0.3; + opacity: 0.2; } } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ba2c954bc..3f8838a7a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -30,8 +30,8 @@ @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); -@import "../components/select-dialog.css" layer(components); @import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); diff --git a/packages/util/package.json b/packages/util/package.json index 43a5f7bb3..c77d99e7c 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.152", + "version": "1.0.153", "private": true, "type": "module", "exports": { diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 1e112b170..d75686a8e 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -54,6 +54,7 @@ export default defineConfig({ "", "config", "providers", + "network", "enterprise", "troubleshooting", "1-0", diff --git a/packages/web/package.json b/packages/web/package.json index f79b7ac36..9d028edbb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.152", + "version": "1.0.153", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index eb171b28c..9129db135 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -71,7 +71,7 @@ You can also bind a keyboard shortcut by editing your `keymap.json`: Add to your [JetBrains IDE](https://www.jetbrains.com/) acp.json according to the [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html): -```json title="~/.config/zed/settings.json" +```json title="acp.json" { "agent_servers": { "OpenCode": { diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 42df490cf..c62f12cb3 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,17 +15,21 @@ 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-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [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 | +| 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-goggle-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-wakatime](https://github.com/angrister/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 | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | --- diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index bb3fe811c..052138f68 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -29,6 +29,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | dart | .dart | `dart` command available | | ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | | terraform | .tf, .tfvars | `terraform` command available | +| gleam | .gleam | `gleam` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 975e9dea1..236b4db82 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -36,6 +36,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | ocaml-lsp | .ml, .mli | `ocamllsp` command available | | terraform | .tf, .tfvars | Auto-installs from GitHub releases | | bash | .sh, .bash, .zsh, .ksh | Auto-installs bash-language-server | +| gleam | .gleam | `gleam` command available | LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met. diff --git a/packages/web/src/content/docs/network.mdx b/packages/web/src/content/docs/network.mdx new file mode 100644 index 000000000..2fcfd9f65 --- /dev/null +++ b/packages/web/src/content/docs/network.mdx @@ -0,0 +1,57 @@ +--- +title: Network +description: Configure proxies and custom certificates. +--- + +OpenCode supports standard proxy environment variables and custom certificates for enterprise network environments. + +--- + +## Proxy + +OpenCode respects standard proxy environment variables. + +```bash +# HTTPS proxy (recommended) +export HTTPS_PROXY=https://proxy.example.com:8080 + +# HTTP proxy (if HTTPS not available) +export HTTP_PROXY=http://proxy.example.com:8080 + +# Bypass proxy for local server (required) +export NO_PROXY=localhost,127.0.0.1 +``` + +:::caution +The TUI communicates with a local HTTP server. You must bypass the proxy for this connection to prevent routing loops. +::: + +You can configure the server's port and hostname using [CLI flags](/docs/cli#run). + +--- + +### Authenticate + +If your proxy requires basic authentication, include credentials in the URL. + +```bash +export HTTPS_PROXY=http://username:password@proxy.example.com:8080 +``` + +:::caution +Avoid hardcoding passwords. Use environment variables or secure credential storage. +::: + +For proxies requiring advanced authentication like NTLM or Kerberos, consider using an LLM Gateway that supports your authentication method. + +--- + +## Custom certificates + +If your enterprise uses custom CAs for HTTPS connections, configure OpenCode to trust them. + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/ca-cert.pem +``` + +This works for both proxy connections and direct API access. diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f674b07a3..6291f7177 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.152", + "version": "1.0.153", "publisher": "sst-dev", "repository": { "type": "git",