Compare commits

...

48 Commits

Author SHA1 Message Date
opencode 56dde2cc83 release: v1.0.154 2025-12-15 16:01:15 +00:00
Dax Raad d2ce368a3f ci: update publish workflow concurrency to include version inputs and upgrade ubuntu runner to 24.04 2025-12-15 10:14:20 -05:00
GitHub Action f492122d59 chore: format code 2025-12-15 14:59:05 +00:00
Dax Raad b0f77da56c core: reorganize agent configuration to separate primary agents (build, plan) from subagents 2025-12-15 09:58:23 -05:00
Dax Raad 274b86b19b ci: fix AppImage build hanging by using portable appimage format
- Add appimage target back to tauri.conf.json
- Force reinstall tauri-cli from feat/truly-portable-appimage branch
- Add 10 minute timeout to prevent indefinite hangs
- Add logging to verify correct tauri-cli version is installed

This fixes the issue where AppImage builds would hang at 'Running input plugin: gtk' by using the new portable appimage format that bypasses linuxdeploy entirely.
2025-12-15 09:47:19 -05:00
René 2ca118db59 docs: Fix Wakatime repository link in ecosystem.mdx (#5552)
Co-authored-by: Github Action <action@github.com>
2025-12-15 08:32:06 -06:00
David Hill a0c0e2b5c3 fix: add tooltip to close review tab 2025-12-15 14:22:57 +00:00
David Hill d43fbec12d fix: prompt input using border shadow 2025-12-15 14:14:10 +00:00
David Hill bb426112ed fix: replace agents dropdown with shadow border 2025-12-15 14:09:47 +00:00
David Hill d2217bb825 fix: fix width and padding on agent select
- conditionally hide the role=presentation element because it was adding extra gap before the first agent
2025-12-15 14:05:24 +00:00
David Hill ac495bd351 fix: hide prompt input send tooltip when the send button is disabled 2025-12-15 13:47:50 +00:00
David Hill b913eb7acc fix: avatar and icon size in sidebar 2025-12-15 13:21:59 +00:00
David Hill ea65a91b2e fix: remove blue border from prompt input 2025-12-15 13:11:09 +00:00
GitHub Action ed6d749104 ignore: update download stats 2025-12-15 2025-12-15 12:05:05 +00:00
René 9eefcd1b41 Provider fix, anthropic Errorhandling if empty image file is read (#5521) 2025-12-14 23:56:47 -06:00
Nalin Singh 7c1124199e fix: input lip visibility for transparent themes (#5544) 2025-12-14 23:10:33 -06:00
Spoon 5cf126d489 fix(edit): add per-file lock to prevent read-before-write race (#4388) 2025-12-14 23:01:50 -06:00
Aiden Cline 509f7d9617 ignore: fix debug var in last commit 2025-12-14 22:59:30 -06:00
opencode-agent[bot] ae1bf92c81 Add dismiss button to Getting Started box (#5543)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-14 22:58:26 -06:00
DS b021b26e77 feat: restore experimental.chat.messages.transform and add experimental.chat.system.transform hooks (#5542) 2025-12-14 22:51:11 -06:00
Aiden Cline 9555d348de ci: switch model 2025-12-14 22:41:54 -06:00
Brendan Allan 220c564047 tauri: use correct sidecar name 2025-12-15 12:40:49 +08:00
Brendan Allan cf5c0129ac tauri: rename sidecar to opencode-cli 2025-12-15 12:40:14 +08:00
Aiden Cline 543dbe71d2 ci: smart oc 2025-12-14 22:36:46 -06:00
Ravi Kumar 54569b5552 fix(session): fix unshare command not clearing share state (#5523) 2025-12-14 22:05:06 -06:00
GitHub Action 6a09861806 chore: format code 2025-12-15 03:42:56 +00:00
Adam 79a4c65313 fix: test 2025-12-14 21:42:17 -06:00
Adam 654534ac71 fix: update sdk 2025-12-14 21:41:20 -06:00
Adam ba16bfdf3d wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam ad5614bbb9 wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam dda579c8ad wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam 4246cdb069 wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 7ade6d386d wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 2613f44961 wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 62ffeb3987 fix(desktop): auto scroll 2025-12-14 21:38:58 -06:00
Adam 4a8e8f537c wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam a68bee7878 fix(desktop): layout fixes 2025-12-14 21:38:57 -06:00
Mark Jaquith ed33d82535 feat(cli): auto-submit prompt when using --prompt flag (#4510) 2025-12-14 21:06:04 -06:00
Dax Raad 2d63c22d1a fix share link 2025-12-14 22:05:53 -05:00
Dax Raad e22af25076 ci: fix test failures in CI by pre-populating models cache
Tests were failing in CI because the models.json cache file doesn't exist
and the data() macro fallback only works at build time, not runtime.
The preload now pre-fetches models.json and disables the background
refresh to prevent race conditions during test execution.
2025-12-14 21:23:45 -05:00
Dax Raad 622caae9c9 fix desktop updater 2025-12-14 21:12:38 -05:00
Dax fed4776451 LLM cleanup (#5462)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-14 21:11:30 -05:00
Ravi Kumar fdf560c343 fix(tui): --continue selects wrong session (#5513) 2025-12-14 19:50:54 -06:00
GitHub Action fc3ffb2bf9 chore: format code 2025-12-14 23:14:05 +00:00
Martijn Baay 7368342bab feat: add experimental.continue_loop_on_deny config option (#4729)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-14 17:13:32 -06:00
Dax Raad c8fc910533 ignore: simplify download page to use GitHub latest redirect URLs 2025-12-14 17:10:59 -05:00
GitHub Action 0f9ef84d55 chore: format code 2025-12-14 22:05:28 +00:00
Dax Raad 74b5c285cf disable app image 2025-12-14 17:04:46 -05:00
97 changed files with 2274 additions and 1907 deletions
+1 -1
View File
@@ -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
+7 -2
View File
@@ -21,7 +21,7 @@ on:
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
permissions:
id-token: write
@@ -166,10 +166,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage
- name: Install tauri-cli from portable appimage branch
if: contains(matrix.settings.host, 'ubuntu')
run: |
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
echo "Installed tauri-cli version:"
cargo tauri --version
- name: Build and upload artifacts
timeout-minutes: 10
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -10,4 +10,5 @@
"options": {},
},
},
"mcp": {},
}
+1
View File
@@ -170,3 +170,4 @@
| 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) |
+15 -15
View File
@@ -20,7 +20,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.153",
"version": "1.0.154",
"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.153",
"version": "1.0.154",
"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.153",
"version": "1.0.154",
"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.153",
"version": "1.0.154",
"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.153",
"version": "1.0.154",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -170,7 +170,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -199,7 +199,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -215,7 +215,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.153",
"version": "1.0.154",
"bin": {
"opencode": "./bin/opencode",
},
@@ -307,7 +307,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -327,7 +327,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.153",
"version": "1.0.154",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -338,7 +338,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -351,7 +351,7 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@opencode-ai/desktop": "workspace:*",
"@tauri-apps/api": "^2",
@@ -376,7 +376,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -411,7 +411,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"zod": "catalog:",
},
@@ -422,7 +422,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765425892,
"narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=",
"lastModified": 1765644376,
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093",
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -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 (<span data-slot="hide-narrow">Apple </span>Silicon)
</span>
</div>
<a href={download() + "/opencode-desktop-darwin-aarch64.dmg"} data-component="action-button">
<a href={downloadUrl + "/opencode-desktop-darwin-aarch64.dmg"} data-component="action-button">
Download
</a>
</div>
@@ -131,7 +123,7 @@ export default function Download() {
</span>
<span>macOS (Intel)</span>
</div>
<a href={download() + "/opencode-desktop-darwin-x64.dmg"} data-component="action-button">
<a href={downloadUrl + "/opencode-desktop-darwin-x64.dmg"} data-component="action-button">
Download
</a>
</div>
@@ -154,7 +146,7 @@ export default function Download() {
</span>
<span>Windows (x64)</span>
</div>
<a href={download() + "/opencode-desktop-windows-x64.exe"} data-component="action-button">
<a href={downloadUrl + "/opencode-desktop-windows-x64.exe"} data-component="action-button">
Download
</a>
</div>
@@ -170,7 +162,7 @@ export default function Download() {
</span>
<span>Linux (.deb)</span>
</div>
<a href={download() + "/opencode-desktop-linux-amd64.deb"} data-component="action-button">
<a href={downloadUrl + "/opencode-desktop-linux-amd64.deb"} data-component="action-button">
Download
</a>
</div>
@@ -186,7 +178,7 @@ export default function Download() {
</span>
<span>Linux (.rpm)</span>
</div>
<a href={download() + "/opencode-desktop-linux-x86_64.rpm"} data-component="action-button">
<a href={downloadUrl + "/opencode-desktop-linux-x86_64.rpm"} data-component="action-button">
Download
</a>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.153",
"version": "1.0.154",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.153",
"version": "1.0.154",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.153",
"version": "1.0.154",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.153",
"version": "1.0.154",
"description": "",
"type": "module",
"exports": {
+30 -27
View File
@@ -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() {
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
<DialogProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
</DialogProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
@@ -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(() => <DialogSelectModel provider={props.provider} />)
}, 1000)
}
function goBack() {
if (methods().length === 1) {
dialog.replace(() => <DialogSelectProvider />)
return
}
if (store.authorization) {
setStore("authorization", undefined)
setStore("methodIndex", undefined)
return
}
if (store.methodIndex) {
setStore("methodIndex", undefined)
return
}
dialog.replace(() => <DialogSelectProvider />)
}
return (
<Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
Login with Claude Pro/Max
</Match>
<Match when={true}>Connect {provider().name}</Match>
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
<Match when={store.methodIndex === undefined}>
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
<div class="">
<List
ref={(ref) => (listRef = ref)}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{i.label}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Spinner />
<span>Authorization in progress...</span>
</div>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>Authorization failed: {store.error}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
{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 (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
OpenCode Zen gives you access to a curated set of reliable optimized models for coding
agents.
</div>
<div class="text-14-regular text-text-base">
With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
</div>
<div class="text-14-regular text-text-base">
Visit{" "}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
opencode.ai/zen
</Link>{" "}
to collect your API key.
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
Enter your {provider().name} API key to connect your account and use {provider().name} models
in OpenCode.
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${provider().name} API key`}
placeholder="API key"
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{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 (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
code to connect your account and use {provider().name} models in OpenCode.
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${method()?.label} authorization code`}
placeholder="Authorization code"
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{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 (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
connect your account and use {provider().name} models in OpenCode.
</div>
<TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>Waiting for authorization...</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</Dialog>
)
}
@@ -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 (
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
<List
class="px-2.5"
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${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) => (
<div class="w-full flex items-center justify-between gap-x-2.5">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
</div>
</div>
)}
</List>
</Dialog>
)
}
@@ -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 (
<Dialog title="Select file">
<List
class="px-2.5"
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
session.layout.openTab("file://" + path)
}
dialog.clear()
}}
>
{(i) => (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
</div>
)}
</List>
</Dialog>
)
}
@@ -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 (
<Dialog title="Select model">
<div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
<List
ref={(ref) => (listRef = ref)}
items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.clear()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>Free</Tag>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<div />
<div />
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
<div class="w-full">
<List
class="w-full"
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
dialog.replace(() => <DialogConnectProvider provider={x.id} />)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
dialog.replace(() => <DialogSelectProvider />)
}}
>
View all providers
</Button>
</div>
</div>
</div>
</div>
</Dialog>
)
}
@@ -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
title="Select model"
action={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => dialog.replace(() => <DialogSelectProvider />)}
>
Connect provider
</Button>
}
>
<List
class="px-2.5"
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${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) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
onClick={() => dialog.replace(() => <DialogManageModels />)}
>
Manage models
</Button>
</Dialog>
)
}
@@ -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 (
<Dialog title="Connect provider">
<List
class="px-2.5"
search={{ placeholder: "Search providers", autofocus: true }}
activeIcon="plus-small"
key={(x) => 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(() => <DialogConnectProvider provider={x.id} />)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</List>
</Dialog>
)
}
+16 -211
View File
@@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, 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"
@@ -9,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
@@ -64,7 +59,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sync = useSync()
const local = useLocal()
const session = useSession()
const layout = useLayout()
const dialog = useDialog()
const providers = useProviders()
let editorRef!: HTMLDivElement
@@ -584,8 +579,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<form
onSubmit={handleSubmit}
classList={{
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-md overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
[props.class ?? ""]: !!props.class,
}}
>
@@ -618,211 +613,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize"
variant="ghost"
/>
<Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
<Show when={layout.dialog.opened() === "model"}>
<Switch>
<Match when={providers.paid().length > 0}>
{iife(() => {
const models = createMemo(() =>
local.model
.list()
.filter((m) =>
layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true,
),
)
return (
<SelectDialog
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
title="Select model"
placeholder="Search models"
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
}
actions={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => layout.dialog.open("provider")}
>
Connect provider
</Button>
}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</SelectDialog>
)
})}
</Match>
<Match when={true}>
{iife(() => {
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return (
<Dialog
modal
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("model")
} else {
layout.dialog.close("model")
}
}}
>
<Dialog.Header>
<Dialog.Title>Select model</Dialog.Title>
<Dialog.CloseButton tabIndex={-1} />
</Dialog.Header>
<Dialog.Body>
<div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
<List
ref={(ref) => (listRef = ref)}
items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
layout.dialog.close("model")
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>Free</Tag>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<div />
<div />
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">
Add more models from popular providers
</div>
<div class="w-full">
<List
class="w-full"
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
layout.dialog.connect(x.id)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">
Connect with Claude Pro/Max or API key
</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
layout.dialog.open("provider")
}}
>
View all providers
</Button>
</div>
</div>
</div>
</div>
</Dialog.Body>
</Dialog>
)
})}
</Match>
</Switch>
</Show>
</div>
<Tooltip
placement="top"
inactive={!session.prompt.dirty() && !session.working()}
value={
<Switch>
<Match when={session.working()}>
+4 -70
View File
@@ -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<AvatarColorKey>()
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", {})
},
},
}
},
})
+77 -21
View File
@@ -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<string, ModelKey>
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")
},
}
})()
@@ -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 (
<DataProvider data={sync.data} directory={directory()}>
<LocalProvider>{props.children}</LocalProvider>
<LocalProvider>
<DialogRoot>{props.children}</DialogRoot>
</LocalProvider>
</DataProvider>
)
})}
+16 -485
View File
@@ -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(() => <DialogSelectProvider />)
}
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)
@@ -192,7 +173,7 @@ export default function Layout(props: ParentProps) {
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class="relative size-6 shrink-0">
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
@@ -205,7 +186,7 @@ export default function Layout(props: ParentProps) {
<Show when={props.expandable}>
<Icon
name="chevron-right"
size="large"
size="normal"
class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
/>
</Show>
@@ -272,7 +253,7 @@ export default function Layout(props: ParentProps) {
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg"
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar
@@ -321,7 +302,7 @@ export default function Layout(props: ParentProps) {
}
return (
<div
class="group/session relative w-full pl-4 pr-1 py-1 rounded-md cursor-default transition-colors
class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<Tooltip placement="right" value={session.title} gutter={10}>
@@ -379,7 +360,7 @@ export default function Layout(props: ParentProps) {
</For>
<Show when={sessions().length === 0}>
<div
class="group/session relative w-full pl-4 pr-1 py-1 rounded-md cursor-default transition-colors
class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch w-full">
@@ -575,456 +556,6 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<Show when={layout.dialog.opened() === "provider"}>
<SelectDialog
defaultOpen
title="Connect provider"
placeholder="Search providers"
activeIcon="plus-small"
key={(x) => x?.id}
items={providers.all}
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) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</SelectDialog>
</Show>
<Show when={layout.dialog.opened() === "connect"}>
{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 (
<Dialog
modal
defaultOpen
onOpenChange={(open) => {
if (open) {
layout.dialog.open("connect")
} else {
layout.dialog.close("connect")
}
}}
>
<Dialog.Header class="px-4.5">
<Dialog.Title class="flex items-center">
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
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")
}}
/>
</Dialog.Title>
<Dialog.CloseButton tabIndex={-1} />
</Dialog.Header>
<Dialog.Body>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match
when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}
>
Login with Claude Pro/Max
</Match>
<Match when={true}>Connect {provider().name}</Match>
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
<Match when={store.method === undefined}>
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
<div class="">
<List
ref={(ref) => (listRef = ref)}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div
class="w-2.5 h-0.5 bg-icon-strong-base hidden"
data-slot="list-item-extra-icon"
/>
</div>
<span>{i.label}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Spinner />
<span>Authorization in progress...</span>
</div>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>Authorization failed: {store.error}</span>
</div>
</div>
</Match>
<Match when={store.method?.type === "api"}>
{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 (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
OpenCode Zen gives you access to a curated set of reliable optimized models for
coding agents.
</div>
<div class="text-14-regular text-text-base">
With a single API key youll get access to models such as Claude, GPT, Gemini,
GLM and more.
</div>
<div class="text-14-regular text-text-base">
Visit{" "}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
opencode.ai/zen
</Link>{" "}
to collect your API key.
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
Enter your {provider().name} API key to connect your account and use{" "}
{provider().name} models in OpenCode.
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${provider().name} API key`}
placeholder="API key"
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.method?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{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 (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> to collect your
authorization code to connect your account and use {provider().name} models in
OpenCode.
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${store.method?.label} authorization code`}
placeholder="Authorization code"
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{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 (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
below to connect your account and use {provider().name} models in OpenCode.
</div>
<TextField
label="Confirmation code"
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>Waiting for authorization...</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</Dialog.Body>
</Dialog>
)
})}
</Show>
</div>
<Toast.Region />
</div>
+13 -42
View File
@@ -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(() => <DialogSelectFile />)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "t") {
@@ -279,7 +280,11 @@ export default function Page() {
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />}
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
</Tooltip>
}
hideCloseButton
onClick={() => props.onTabClick(props.tab)}
>
@@ -357,7 +362,9 @@ export default function Page() {
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
</Tooltip>
}
>
<div class="flex items-center gap-3">
@@ -388,7 +395,7 @@ export default function Page() {
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
onClick={() => dialog.replace(() => <DialogSelectFile />)}
/>
</Tooltip>
</div>
@@ -610,42 +617,6 @@ export default function Page() {
</ul>
</Show>
</div>
<Show when={store.fileSelectOpen}>
<SelectDialog
defaultOpen
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => {
if (x) {
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</SelectDialog>
</Show>
</div>
<Show when={layout.terminal.opened()}>
<div
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.153",
"version": "1.0.154",
"private": true,
"type": "module",
"scripts": {
@@ -138,18 +138,13 @@ const getData = query(async (shareID) => {
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())
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.153"
version = "1.0.154"
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.153/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.154/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.153/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.154/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.153/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.154/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.153/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.154/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.153",
"version": "1.0.154",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.153",
"version": "1.0.154",
"name": "opencode",
"type": "module",
"private": true,
+58 -38
View File
@@ -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(),
@@ -101,6 +107,24 @@ export namespace Agent {
)
const result: Record<string, Info> = {
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
@@ -112,7 +136,8 @@ export namespace Agent {
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
native: true,
hidden: true,
},
explore: {
name: "explore",
@@ -124,48 +149,43 @@ 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,
},
build: {
name: "build",
tools: { ...defaultTools },
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
tools: {
"*": false,
},
options: {},
permission: agentPermission,
mode: "primary",
builtIn: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
title: {
name: "title",
mode: "primary",
builtIn: true,
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: {},
},
}
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,
@@ -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.
@@ -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 users 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.)
</rules>
<examples>
+2 -2
View File
@@ -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)
})
+4 -4
View File
@@ -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)
}
}
+3 -1
View File
@@ -218,7 +218,9 @@ function App() {
let continued = false
createEffect(() => {
if (continued || sync.status !== "complete" || !args.continue) return
const match = sync.data.session.find((x) => x.parentID === undefined)?.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 })
@@ -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,
}
}),
)
@@ -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,
@@ -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,17 +873,24 @@ export function Prompt(props: PromptProps) {
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={{
...EmptyBorder,
horizontal: "▀",
}}
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
@@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const agent = iife(() => {
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
}>({
@@ -57,6 +57,7 @@ export function Home() {
} else if (args.prompt) {
prompt.set({ input: args.prompt, parts: [] })
once = true
prompt.submit()
}
})
const directory = useDirectory()
@@ -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()
},
},
@@ -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 (
<Show when={session()}>
@@ -249,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) {
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<Show when={!hasProviders()}>
<Show when={!hasProviders() && !gettingStartedDismissed()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
@@ -263,9 +265,14 @@ export function Sidebar(props: { sessionID: string }) {
</text>
<box flexGrow={1} gap={1}>
<text fg={theme.text}>
<b>Getting started</b>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<b>Getting started</b>
</text>
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
</text>
</box>
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme.textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
+7
View File
@@ -668,10 +668,16 @@ export namespace Config {
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
// primary
plan: Agent.optional(),
build: Agent.optional(),
// subagent
general: Agent.optional(),
explore: Agent.optional(),
// specialized
title: Agent.optional(),
summary: Agent.optional(),
compaction: Agent.optional(),
})
.catchall(Agent)
.optional()
@@ -783,6 +789,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(),
})
+26
View File
@@ -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<string, Promise<void>>()
return {
read,
locks,
}
})
@@ -25,6 +31,26 @@ export namespace FileTime {
return state().read[sessionID]?.[file]
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
const current = state()
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
let release: () => void = () => {}
const nextLock = new Promise<void>((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`)
+1
View File
@@ -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"
+2
View File
@@ -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,
+5 -1
View File
@@ -392,6 +392,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",
@@ -470,6 +471,7 @@ export namespace Provider {
},
interleaved: model.interleaved ?? false,
},
release_date: model.release_date,
}
}
@@ -602,6 +604,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
}
@@ -858,7 +862,7 @@ export namespace Provider {
return info
}
export async function getLanguage(model: Model) {
export async function getLanguage(model: Model): Promise<LanguageModelV2> {
const s = await state()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
@@ -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)
+21 -75
View File
@@ -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"),
+3 -13
View File
@@ -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) {
+190
View File
@@ -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<string, Tool>
retries?: number
}
export type StreamOutput = StreamTextResult<ToolSet, unknown>
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<StreamInput, "tools" | "agent" | "user">) {
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
}
}
+14 -6
View File
@@ -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<typeof WithParts>
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",
+6 -13
View File
@@ -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<ReturnType<typeof create>>
export type Result = Awaited<ReturnType<Info["process"]>>
export type StreamInput = Parameters<typeof streamText>[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<string, MessageV2.ReasoningPart> = {}
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]
}
+71 -264
View File
@@ -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<typeof PromptInput>
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<PromptInput["parts"]> {
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,30 +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),
topK: ProviderTransform.topK(model),
options: pipe(
{},
mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)),
mergeDeep(model.options),
mergeDeep(agent.options),
),
},
)
if (step === 1) {
SessionSummary.summarize({
@@ -523,135 +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,
topK: params.topK,
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
@@ -675,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
@@ -709,6 +533,7 @@ export namespace SessionPrompt {
tools?: Record<string, boolean>
processor: SessionProcessor.Info
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
input.agent.tools,
@@ -778,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
@@ -857,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)),
}
@@ -1148,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"),
@@ -1216,6 +1039,7 @@ export namespace SessionPrompt {
sessionID: input.sessionID,
parentID: userMsg.id,
mode: input.agent,
agent: input.agent,
cost: 0,
path: {
cwd: Instance.directory,
@@ -1510,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",
@@ -1555,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(/<think>[\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(/<think>[\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
})
}
}
+37 -55
View File
@@ -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)
+1 -29
View File
@@ -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]
}
}
}
+1 -1
View File
@@ -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) {
-1
View File
@@ -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 })
+4 -4
View File
@@ -76,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))
@@ -97,6 +97,7 @@ export const EditTool = Tool.define("edit", {
await Bus.publish(File.Event.Edited, {
file: filePath,
})
FileTime.read(ctx.sessionID, filePath)
return
}
@@ -133,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)
+10 -4
View File
@@ -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
}
+12
View File
@@ -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"]
@@ -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.",
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+6
View File
@@ -185,6 +185,12 @@ export interface Hooks {
}[]
},
) => Promise<void>
"experimental.chat.system.transform"?: (
input: {},
output: {
system: string[]
},
) => Promise<void>
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+4 -4
View File
@@ -1203,10 +1203,10 @@ export class Session extends HeyApiClient {
}
agent?: string
noReply?: boolean
system?: string
tools?: {
[key: string]: boolean
}
system?: string
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
@@ -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<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
@@ -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" },
],
},
+49 -39
View File
@@ -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<Todo>
}
}
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<Todo>
}
}
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
@@ -1412,6 +1413,9 @@ export type Config = {
build?: AgentConfig
general?: AgentConfig
explore?: AgentConfig
title?: AgentConfig
summary?: AgentConfig
compaction?: AgentConfig
[key: string]: AgentConfig | undefined
}
/**
@@ -1518,6 +1522,10 @@ export type Config = {
* Tools that should only be available to primary agents.
*/
primary_tools?: Array<string>
/**
* Continue the agent loop when a tool call is denied
*/
continue_loop_on_deny?: boolean
}
}
@@ -1657,6 +1665,7 @@ export type Model = {
headers: {
[key: string]: string
}
release_date: string
}
export type Provider = {
@@ -1734,7 +1743,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 +2807,10 @@ export type SessionPromptData = {
}
agent?: string
noReply?: boolean
system?: string
tools?: {
[key: string]: boolean
}
system?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
@@ -2892,10 +2902,10 @@ export type SessionPromptAsyncData = {
}
agent?: string
noReply?: boolean
system?: string
tools?: {
[key: string]: boolean
}
system?: string
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
+116 -81
View File
@@ -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"
},
@@ -7988,6 +7992,15 @@
},
"explore": {
"$ref": "#/components/schemas/AgentConfig"
},
"title": {
"$ref": "#/components/schemas/AgentConfig"
},
"summary": {
"$ref": "#/components/schemas/AgentConfig"
},
"compaction": {
"$ref": "#/components/schemas/AgentConfig"
}
},
"additionalProperties": {
@@ -8279,6 +8292,10 @@
"items": {
"type": "string"
}
},
"continue_loop_on_deny": {
"description": "Continue the agent loop when a tool call is denied",
"type": "boolean"
}
}
}
@@ -8673,9 +8690,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 +8948,10 @@
"type": "string",
"enum": ["subagent", "primary", "all"]
},
"builtIn": {
"native": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"topP": {
@@ -8997,7 +9032,7 @@
"maximum": 9007199254740991
}
},
"required": ["name", "mode", "builtIn", "permission", "tools", "options"]
"required": ["name", "mode", "permission", "tools", "options"]
},
"MCPStatusConnected": {
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/tauri",
"private": true,
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+1 -1
View File
@@ -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}`)
+2 -2
View File
@@ -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<dyn std::error::Er
fn spawn_sidecar(app: &AppHandle, port: u16) -> CommandChild {
let (mut rx, child) = app
.shell()
.sidecar("opencode")
.sidecar("opencode-cli")
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
+3 -3
View File
@@ -19,9 +19,9 @@
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "appimage"],
"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"]
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.153",
"version": "1.0.154",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
+1
View File
@@ -9,6 +9,7 @@
user-select: none;
cursor: default;
outline: none;
white-space: nowrap;
&[data-variant="primary"] {
background-color: var(--icon-strong-base);
+25 -4
View File
@@ -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;
+36 -87
View File
@@ -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 (
<Kobalte {...others}>
<Show when={props.trigger}>
<Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
{props.trigger}
</Kobalte.Trigger>
</Show>
<Kobalte.Portal>
<Kobalte.Overlay data-component="dialog-overlay" />
<div data-component="dialog">
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Content>
</div>
</div>
</Kobalte.Portal>
</Kobalte>
<div data-component="dialog">
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
>
<Show when={props.title || props.action}>
<div data-slot="dialog-header">
<Show when={props.title}>
<Kobalte.Title data-slot="dialog-title">{props.title}</Kobalte.Title>
</Show>
<Switch>
<Match when={props.action}>{props.action}</Match>
<Match when={true}>
<Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" />
</Match>
</Switch>
</div>
</Show>
<Show when={props.description}>
<Kobalte.Description data-slot="dialog-description">{props.description}</Kobalte.Description>
</Show>
<div data-slot="dialog-body">{props.children}</div>
</Kobalte.Content>
</div>
</div>
)
}
function DialogHeader(props: ComponentProps<"div">) {
return <div data-slot="dialog-header" {...props} />
}
function DialogBody(props: ComponentProps<"div">) {
return <div data-slot="dialog-body" {...props} />
}
function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
return <Kobalte.Title data-slot="dialog-title" {...props} />
}
function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
return <Kobalte.Description data-slot="dialog-description" {...props} />
}
function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
return <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" {...props} />
}
export const Dialog = Object.assign(DialogRoot, {
Header: DialogHeader,
Title: DialogTitle,
Description: DialogDescription,
CloseButton: DialogCloseButton,
Body: DialogBody,
})
+38
View File
@@ -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;
+103 -60
View File
@@ -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<T> extends FilteredListProps<T> {
class?: string
@@ -10,6 +17,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
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<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
const [internalFilter, setInternalFilter] = createSignal("")
const [store, setStore] = createStore({
mouseActive: false,
})
const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
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<T>(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<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
})
return (
<div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show
when={flat().length > 0}
fallback={
<div data-slot="list-empty-state">
<div data-slot="list-message">
{props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
</div>
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={!!props.search}>
<div data-slot="list-search">
<div data-slot="list-search-container">
<Icon name="magnifying-glass" />
<TextField
autofocus={searchProps().autofocus}
variant="ghost"
data-slot="list-search-input"
type="text"
value={internalFilter()}
onChange={setInternalFilter}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
}
>
<For each={grouped()}>
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<div data-slot="list-header">{group.category}</div>
</Show>
<div data-slot="list-items">
<For each={group.items}>
{(item, i) => (
<button
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onMouseMove={() => {
setStore("mouseActive", true)
setActive(props.key(item))
}}
>
{props.children(item)}
<Show when={item === props.current}>
<Icon data-slot="list-item-selected-icon" name="check-small" />
</Show>
<Show when={props.activeIcon}>
{(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
</Show>
</button>
)}
</For>
<Show when={internalFilter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
</Show>
</div>
</Show>
<div ref={setScrollRef} data-slot="list-scroll">
<Show
when={flat().length > 0}
fallback={
<div data-slot="list-empty-state">
<div data-slot="list-message">
{props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
</div>
</div>
)}
</For>
</Show>
}
>
<For each={grouped()}>
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<div data-slot="list-header">{group.category}</div>
</Show>
<div data-slot="list-items">
<For each={group.items}>
{(item, i) => (
<button
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onMouseMove={() => {
setStore("mouseActive", true)
setActive(props.key(item))
}}
>
{props.children(item)}
<Show when={item === props.current}>
<Icon data-slot="list-item-selected-icon" name="check-small" />
</Show>
<Show when={props.activeIcon}>
{(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
</Show>
</button>
)}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
)
}
@@ -1,44 +0,0 @@
[data-slot="select-dialog-content"] {
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 20px;
padding: 0 10px;
[data-slot="dialog-body"] {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
[data-component="select-dialog-input"] {
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="select-dialog-input-container"] {
display: flex;
align-items: center;
gap: 16px;
flex: 1 0 0;
/* [data-slot="select-dialog-icon"] {} */
[data-slot="select-dialog-input"] {
width: 100%;
}
}
/* [data-slot="select-dialog-clear-button"] {} */
}
@@ -1,102 +0,0 @@
import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js"
import { Dialog, DialogProps } from "./dialog"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { List, ListRef, ListProps } from "./list"
import { TextField } from "./text-field"
interface SelectDialogProps<T>
extends Omit<ListProps<T>, "filter">,
Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
title: string
placeholder?: string
actions?: JSX.Element
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
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 (
<Dialog modal {...dialog} onOpenChange={handleOpenChange}>
<Dialog.Header>
<Dialog.Title>{others.title}</Dialog.Title>
<Show when={others.actions}>{others.actions}</Show>
<Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: others.actions ? "none" : undefined }} />
</Dialog.Header>
<div data-slot="select-dialog-content">
<div data-component="select-dialog-input">
<div data-slot="select-dialog-input-container">
<Icon name="magnifying-glass" />
<TextField
ref={inputRef}
autofocus
variant="ghost"
data-slot="select-dialog-input"
type="text"
value={filter()}
onChange={setFilter}
onKeyDown={handleKey}
placeholder={others.placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
<Show when={filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
</Show>
</div>
<Dialog.Body>
<List
ref={(ref) => {
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}
</List>
</Dialog.Body>
</div>
</Dialog>
)
}
+6 -5
View File
@@ -23,12 +23,9 @@
max-width: 23rem;
overflow: hidden;
border-radius: var(--radius-md);
border-width: 1px;
border-style: solid;
border-color: var(--border-weak-base);
background-color: var(--surface-raised-stronger-non-alpha);
padding: 2px;
box-shadow: var(--shadow-xs);
box-shadow: var(--shadow-xs-border);
z-index: 50;
&[data-closed] {
@@ -47,11 +44,14 @@
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 2px;
&:focus {
outline: none;
}
> *:not([role="presentation"]) + *:not([role="presentation"]) {
margin-top: 2px;
}
}
/* [data-slot="select-section"] { */
@@ -62,6 +62,7 @@
display: flex;
align-items: center;
padding: 0 6px 0 6px;
gap: 12px;
border-radius: var(--radius-sm);
/* text-12-medium */
@@ -37,7 +37,6 @@
top: 0;
background-color: var(--background-stronger);
z-index: 21;
/* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */
}
[data-slot="session-turn-response-trigger"] {
@@ -297,7 +296,6 @@
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
/* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */
}
[data-slot="session-turn-collapsible-trigger-content"] {
+9 -10
View File
@@ -50,7 +50,6 @@ export function SessionTurn(
let scrollRef: HTMLDivElement | undefined
const [state, setState] = createStore({
contentRef: undefined as HTMLDivElement | undefined,
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
userScrolled: false,
@@ -61,6 +60,8 @@ export function SessionTurn(
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
@@ -80,7 +81,7 @@ export function SessionTurn(
if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
setState("autoScrolling", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" })
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
requestAnimationFrame(() => {
setState("autoScrolling", false)
})
@@ -93,13 +94,6 @@ export function SessionTurn(
}
})
createResizeObserver(
() => state.contentRef,
() => {
scrollToBottom()
},
)
createResizeObserver(
() => state.stickyTitleRef,
({ height }) => {
@@ -119,7 +113,7 @@ export function SessionTurn(
return (
<div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${state.scrollY}px` }}>
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
<div ref={(el) => setState("contentRef", el)} onClick={handleInteraction}>
<div onClick={handleInteraction}>
<Show when={message()}>
{(message) => {
const assistantMessages = createMemo(() => {
@@ -221,6 +215,11 @@ export function SessionTurn(
})
}
createEffect(() => {
lastPart()
scrollToBottom()
})
const [store, setStore] = createStore({
status: rawStatus(),
stepsExpanded: true,
+131
View File
@@ -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;
}
}
+30
View File
@@ -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<ComponentProps<typeof Kobalte>> {
hideLabel?: boolean
description?: string
}
export function Switch(props: SwitchProps) {
const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
const resolved = children(() => local.children)
return (
<Kobalte {...others} data-component="switch">
<Kobalte.Input data-slot="switch-input" />
<Show when={resolved()}>
<Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
{resolved()}
</Kobalte.Label>
</Show>
<Show when={local.description}>
<Kobalte.Description data-slot="switch-description">{local.description}</Kobalte.Description>
</Show>
<Kobalte.ErrorMessage data-slot="switch-error" />
<Kobalte.Control data-slot="switch-control">
<Kobalte.Thumb data-slot="switch-thumb" />
</Kobalte.Control>
</Kobalte>
)
}
+79
View File
@@ -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}
<Show when={dialog.stack.length > 0}>
<div data-component="dialog-stack">
<For each={dialog.stack}>
{(item, index) => (
<Show when={index() === dialog.stack.length - 1}>
<Kobalte
modal
defaultOpen
onOpenChange={(open) => {
if (!open) {
item.onClose?.()
dialog.pop()
}
}}
>
<Kobalte.Portal>
<Kobalte.Overlay data-component="dialog-overlay" />
{typeof item.element === "function" ? item.element() : item.element}
</Kobalte.Portal>
</Kobalte>
</Show>
)}
</For>
</div>
</Show>
</>
)
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./helper"
export * from "./data"
export * from "./diff"
export * from "./dialog"
+7 -4
View File
@@ -5,7 +5,7 @@ import { createStore } from "solid-js/store"
import { createList } from "solid-list"
export interface FilteredListProps<T> {
items: (filter: string) => T[] | Promise<T[]>
items: T[] | ((filter: string) => T[] | Promise<T[]>)
key: (item: T) => string
filterKeys?: string[]
current?: T
@@ -19,10 +19,13 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
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<T[]>)(needle))) || []
const result = pipe(
all,
(x) => {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.153",
"version": "1.0.154",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.153",
"version": "1.0.154",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -27,7 +27,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [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-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 |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.153",
"version": "1.0.154",
"publisher": "sst-dev",
"repository": {
"type": "git",