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) => (
+
+ )}
+
+
+
+
+
+
+
+ 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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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"
/>
-