mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
Merge branch 'dev' into cloudflare-ai-gateway
This commit is contained in:
commit
f3364a8b28
115 changed files with 2752 additions and 2073 deletions
2
.github/workflows/opencode.yml
vendored
2
.github/workflows/opencode.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
|
|
@ -64,6 +64,12 @@ jobs:
|
|||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@
|
|||
"options": {},
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
}
|
||||
|
|
|
|||
2
STATS.md
2
STATS.md
|
|
@ -169,3 +169,5 @@
|
|||
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
||||
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
||||
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
||||
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
||||
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
||||
|
|
|
|||
37
bun.lock
37
bun.lock
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -133,6 +133,7 @@
|
|||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
|
|
@ -169,7 +170,7 @@
|
|||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
|
@ -198,7 +199,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
|
|
@ -214,7 +215,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -306,7 +307,7 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -326,7 +327,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
|
@ -337,7 +338,7 @@
|
|||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
|
|
@ -350,7 +351,7 @@
|
|||
},
|
||||
"packages/tauri": {
|
||||
"name": "@opencode-ai/tauri",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@opencode-ai/desktop": "workspace:*",
|
||||
"@tauri-apps/api": "^2",
|
||||
|
|
@ -375,7 +376,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -410,7 +411,7 @@
|
|||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
|
@ -421,7 +422,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
|
@ -477,7 +478,7 @@
|
|||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.3",
|
||||
"@types/bun": "1.3.4",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
|
|
@ -1703,7 +1704,7 @@
|
|||
|
||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
|
||||
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
|
|
@ -2009,7 +2010,7 @@
|
|||
|
||||
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ="
|
||||
"nodeModules": "sha256-lgPsYtNJT7a+mDk5cTiEJLlBnTMTjxZCl8bw5WxcuaM="
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.3",
|
||||
"packageManager": "bun@1.3.4",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.3",
|
||||
"@types/bun": "1.3.4",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"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,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
383
packages/desktop/src/components/dialog-connect-provider.tsx
Normal file
383
packages/desktop/src/components/dialog-connect-provider.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
50
packages/desktop/src/components/dialog-manage-models.tsx
Normal file
50
packages/desktop/src/components/dialog-manage-models.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
44
packages/desktop/src/components/dialog-select-file.tsx
Normal file
44
packages/desktop/src/components/dialog-select-file.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
119
packages/desktop/src/components/dialog-select-model-unpaid.tsx
Normal file
119
packages/desktop/src/components/dialog-select-model-unpaid.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
84
packages/desktop/src/components/dialog-select-model.tsx
Normal file
84
packages/desktop/src/components/dialog-select-model.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
64
packages/desktop/src/components/dialog-select-provider.tsx
Normal file
64
packages/desktop/src/components/dialog-select-provider.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +1,7 @@
|
|||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import {
|
||||
createEffect,
|
||||
on,
|
||||
Component,
|
||||
Show,
|
||||
For,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Switch,
|
||||
Match,
|
||||
createSignal,
|
||||
createMemo,
|
||||
} from "solid-js"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
|
||||
|
|
@ -20,21 +9,16 @@ import { useSDK } from "@/context/sdk"
|
|||
import { useNavigate } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List, ListRef } from "@opencode-ai/ui/list"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
interface PromptInputProps {
|
||||
class?: string
|
||||
|
|
@ -75,28 +59,91 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const session = useSession()
|
||||
const layout = useLayout()
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
popoverIsOpen: boolean
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
placeholder: number
|
||||
}>({
|
||||
popoverIsOpen: false,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
})
|
||||
|
||||
const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length))
|
||||
const MAX_HISTORY = 100
|
||||
const [history, setHistory] = makePersisted(
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
{
|
||||
name: "prompt-history.v1",
|
||||
},
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPlaceholder((prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||
}, 6500)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
const clonePromptParts = (prompt: Prompt): Prompt =>
|
||||
prompt.map((part) =>
|
||||
part.type === "text"
|
||||
? { ...part }
|
||||
: {
|
||||
...part,
|
||||
selection: part.selection ? { ...part.selection } : undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
|
||||
|
||||
const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
|
||||
const length = position === "start" ? 0 : promptLength(prompt)
|
||||
session.prompt.set(prompt, length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, length)
|
||||
})
|
||||
}
|
||||
|
||||
const getCaretLineState = () => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false }
|
||||
const range = selection.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect()
|
||||
const editorRect = editorRef.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(editorRef)
|
||||
const paddingTop = parseFloat(style.paddingTop) || 0
|
||||
const paddingBottom = parseFloat(style.paddingBottom) || 0
|
||||
let lineHeight = parseFloat(style.lineHeight)
|
||||
if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16
|
||||
const scrollTop = editorRef.scrollTop
|
||||
let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop
|
||||
if (!Number.isFinite(relativeTop)) relativeTop = scrollTop
|
||||
relativeTop = Math.max(0, relativeTop)
|
||||
let caretHeight = rect.height
|
||||
if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight
|
||||
const relativeBottom = relativeTop + caretHeight
|
||||
const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom)
|
||||
const threshold = Math.max(2, lineHeight / 2)
|
||||
|
||||
return {
|
||||
collapsed: selection.isCollapsed,
|
||||
onFirstLine: relativeTop <= threshold,
|
||||
onLastLine: contentHeight - relativeBottom <= threshold,
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
session.id
|
||||
editorRef.focus()
|
||||
if (session.id) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||
}, 6500)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
|
|
@ -129,17 +176,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
||||
items: local.file.searchFilesAndDirectories,
|
||||
key: (x) => x,
|
||||
onSelect: handleFileSelect,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.model.recent()
|
||||
refetch()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => session.prompt.current(),
|
||||
|
|
@ -221,6 +263,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
setStore("popoverIsOpen", false)
|
||||
}
|
||||
|
||||
if (store.historyIndex >= 0) {
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
}
|
||||
|
||||
session.prompt.set(rawParts, cursorPosition)
|
||||
}
|
||||
|
||||
|
|
@ -296,12 +343,100 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
sessionID: session.id!,
|
||||
})
|
||||
|
||||
const addToHistory = (prompt: Prompt) => {
|
||||
const text = prompt
|
||||
.map((p) => p.content)
|
||||
.join("")
|
||||
.trim()
|
||||
if (!text) return
|
||||
|
||||
const entry = clonePromptParts(prompt)
|
||||
const lastEntry = history.entries[0]
|
||||
if (lastEntry) {
|
||||
const lastText = lastEntry.map((p) => p.content).join("")
|
||||
if (lastText === text) return
|
||||
}
|
||||
|
||||
setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
||||
}
|
||||
|
||||
const navigateHistory = (direction: "up" | "down") => {
|
||||
const entries = history.entries
|
||||
const current = store.historyIndex
|
||||
|
||||
if (direction === "up") {
|
||||
if (entries.length === 0) return false
|
||||
if (current === -1) {
|
||||
setStore("savedPrompt", clonePromptParts(session.prompt.current()))
|
||||
setStore("historyIndex", 0)
|
||||
applyHistoryPrompt(entries[0], "start")
|
||||
return true
|
||||
}
|
||||
if (current < entries.length - 1) {
|
||||
const next = current + 1
|
||||
setStore("historyIndex", next)
|
||||
applyHistoryPrompt(entries[next], "start")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (current > 0) {
|
||||
const next = current - 1
|
||||
setStore("historyIndex", next)
|
||||
applyHistoryPrompt(entries[next], "end")
|
||||
return true
|
||||
}
|
||||
if (current === 0) {
|
||||
setStore("historyIndex", -1)
|
||||
const saved = store.savedPrompt
|
||||
if (saved) {
|
||||
applyHistoryPrompt(saved, "end")
|
||||
setStore("savedPrompt", null)
|
||||
return true
|
||||
}
|
||||
applyHistoryPrompt(DEFAULT_PROMPT, "end")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
|
||||
if (!collapsed) return
|
||||
const cursorPos = getCursorPosition(editorRef)
|
||||
const textLength = promptLength(session.prompt.current())
|
||||
const inHistory = store.historyIndex >= 0
|
||||
const isStart = cursorPos === 0
|
||||
const isEnd = cursorPos === textLength
|
||||
const atAbsoluteStart = onFirstLine && isStart
|
||||
const atAbsoluteEnd = onLastLine && isEnd
|
||||
const allowUp = (inHistory && isEnd) || atAbsoluteStart
|
||||
const allowDown = (inHistory && isStart) || atAbsoluteEnd
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!allowUp) return
|
||||
if (navigateHistory("up")) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowDown) return
|
||||
if (navigateHistory("down")) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event)
|
||||
}
|
||||
|
|
@ -323,6 +458,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
return
|
||||
}
|
||||
|
||||
addToHistory(prompt)
|
||||
setStore("historyIndex", -1)
|
||||
setStore("savedPrompt", null)
|
||||
|
||||
let existing = session.info()
|
||||
if (!existing) {
|
||||
const created = await sdk.client.session.create()
|
||||
|
|
@ -461,7 +600,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
/>
|
||||
<Show when={!session.prompt.dirty()}>
|
||||
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
|
||||
Ask anything... "{PLACEHOLDERS[placeholder()]}"
|
||||
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -474,207 +613,17 @@ 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"]}
|
||||
// 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"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/session"
|
||||
import { usePrefersDark } from "@solid-primitives/media"
|
||||
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
|
|
@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||
let serializeAddon: SerializeAddon
|
||||
let fitAddon: FitAddon
|
||||
let handleResize: () => void
|
||||
const prefersDark = usePrefersDark()
|
||||
|
||||
onMount(async () => {
|
||||
ghostty = await Ghostty.load()
|
||||
|
|
@ -31,10 +33,17 @@ export const Terminal = (props: TerminalProps) => {
|
|||
fontSize: 14,
|
||||
fontFamily: "TX-02, monospace",
|
||||
allowTransparency: true,
|
||||
theme: {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
},
|
||||
theme: prefersDark()
|
||||
? {
|
||||
background: "#191515",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
}
|
||||
: {
|
||||
background: "#fcfcfc",
|
||||
foreground: "#211e1e",
|
||||
cursor: "#211e1e",
|
||||
},
|
||||
scrollback: 10_000,
|
||||
ghostty,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -189,11 +170,13 @@ export default function Layout(props: ParentProps) {
|
|||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
return (
|
||||
<div class="relative size-6 shrink-0">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.icon?.url}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class={`size-full ${props.class ?? ""}`}
|
||||
style={
|
||||
|
|
@ -318,22 +301,20 @@ export default function Layout(props: ParentProps) {
|
|||
)
|
||||
}
|
||||
return (
|
||||
<A
|
||||
href={`${slug()}/session/${session.id}`}
|
||||
class="group/session focus:outline-none cursor-default"
|
||||
<div
|
||||
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}>
|
||||
<div
|
||||
class="relative w-full pl-4 pr-1 py-1 rounded-md
|
||||
group-[.active]/session:bg-surface-raised-base-hover
|
||||
group-hover/session:bg-surface-raised-base-hover
|
||||
group-focus/session:bg-surface-raised-base-hover"
|
||||
<Tooltip placement="right" value={session.title} gutter={10}>
|
||||
<A
|
||||
href={`${slug()}/session/${session.id}`}
|
||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
{session.title}
|
||||
</span>
|
||||
<div class="shrink-0 group-hover/session:hidden mr-1">
|
||||
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<Switch>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
|
|
@ -358,12 +339,6 @@ export default function Layout(props: ParentProps) {
|
|||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1">
|
||||
{/* <IconButton icon="dot-grid" variant="ghost" /> */}
|
||||
<Tooltip placement="right" value="Archive session">
|
||||
<IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={session.summary?.files}>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
|
|
@ -371,29 +346,40 @@ export default function Layout(props: ParentProps) {
|
|||
<Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
</Tooltip>
|
||||
</A>
|
||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||
{/* <IconButton icon="dot-grid" variant="ghost" /> */}
|
||||
<Tooltip placement="right" value="Archive session">
|
||||
<IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={sessions().length === 0}>
|
||||
<A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
|
||||
<Tooltip placement="right" value="New session">
|
||||
<div
|
||||
class="relative w-full pl-4 pr-1 py-1 rounded-md
|
||||
group-[.active]/session:bg-surface-raised-base-hover
|
||||
group-hover/session:bg-surface-raised-base-hover
|
||||
group-focus/session:bg-surface-raised-base-hover"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
New session
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Tooltip placement="right" value="New session">
|
||||
<A
|
||||
href={`${slug()}/session`}
|
||||
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
New session
|
||||
</span>
|
||||
</div>
|
||||
</A>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
</Collapsible.Content>
|
||||
|
|
@ -570,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 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={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>
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
@ -388,7 +389,7 @@ export default function Page() {
|
|||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() => setStore("fileSelectOpen", true)}
|
||||
onClick={() => dialog.replace(() => <DialogSelectFile />)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -610,42 +611,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,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"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())
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.152"
|
||||
version = "1.0.153"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.153/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
FROM alpine
|
||||
FROM alpine AS base
|
||||
|
||||
# Disable the runtime transpiler cache by default inside Docker containers.
|
||||
# On ephemeral containers, the cache is not useful
|
||||
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
|
||||
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
|
||||
RUN apk add libgcc libstdc++ ripgrep
|
||||
ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
|
||||
|
||||
FROM base AS build-amd64
|
||||
COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
|
||||
|
||||
FROM base AS build-arm64
|
||||
COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode
|
||||
|
||||
ARG TARGETARCH
|
||||
FROM build-${TARGETARCH}
|
||||
RUN opencode --version
|
||||
ENTRYPOINT ["opencode"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ for (const item of targets) {
|
|||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
//@ts-ignore (bun types aren't up to date)
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: name.replace(pkg.name, "bun") as any,
|
||||
outfile: `dist/${name}/bin/opencode`,
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
|
||||
|
|
|
|||
|
|
@ -244,8 +244,8 @@ if (!Script.preview) {
|
|||
await $`cd ./dist/homebrew-tap && git push`
|
||||
|
||||
const image = "ghcr.io/sst/opencode"
|
||||
await $`docker build -t ${image}:${Script.version} .`
|
||||
await $`docker push ${image}:${Script.version}`
|
||||
await $`docker tag ${image}:${Script.version} ${image}:latest`
|
||||
await $`docker push ${image}:latest`
|
||||
const platforms = "linux/amd64,linux/arm64"
|
||||
const tags = [`${image}:${Script.version}`, `${image}:latest`]
|
||||
const tagFlags = tags.flatMap((t) => ["-t", t])
|
||||
await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@ import { Config } from "../config/config"
|
|||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
mode: z.enum(["subagent", "primary", "all"]),
|
||||
builtIn: z.boolean(),
|
||||
native: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
|
|
@ -112,7 +118,8 @@ export namespace Agent {
|
|||
options: {},
|
||||
permission: agentPermission,
|
||||
mode: "subagent",
|
||||
builtIn: true,
|
||||
native: true,
|
||||
hidden: true,
|
||||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
|
|
@ -124,30 +131,23 @@ export namespace Agent {
|
|||
...defaultTools,
|
||||
},
|
||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||
prompt: [
|
||||
`You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`,
|
||||
``,
|
||||
`Your strengths:`,
|
||||
`- Rapidly finding files using glob patterns`,
|
||||
`- Searching code and text with powerful regex patterns`,
|
||||
`- Reading and analyzing file contents`,
|
||||
``,
|
||||
`Guidelines:`,
|
||||
`- Use Glob for broad file pattern matching`,
|
||||
`- Use Grep for searching file contents with regex`,
|
||||
`- Use Read when you know the specific file path you need to read`,
|
||||
`- Use Bash for file operations like copying, moving, or listing directory contents`,
|
||||
`- Adapt your search approach based on the thoroughness level specified by the caller`,
|
||||
`- Return file paths as absolute paths in your final response`,
|
||||
`- For clear communication, avoid using emojis`,
|
||||
`- Do not create any files, or run bash commands that modify the user's system state in any way`,
|
||||
``,
|
||||
`Complete the user's search request efficiently and report your findings clearly.`,
|
||||
].join("\n"),
|
||||
prompt: PROMPT_EXPLORE,
|
||||
options: {},
|
||||
permission: agentPermission,
|
||||
mode: "subagent",
|
||||
builtIn: true,
|
||||
native: true,
|
||||
},
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
hidden: true,
|
||||
prompt: PROMPT_COMPACTION,
|
||||
tools: {
|
||||
"*": false,
|
||||
},
|
||||
options: {},
|
||||
permission: agentPermission,
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
|
|
@ -155,7 +155,27 @@ export namespace Agent {
|
|||
options: {},
|
||||
permission: agentPermission,
|
||||
mode: "primary",
|
||||
builtIn: true,
|
||||
native: true,
|
||||
},
|
||||
title: {
|
||||
name: "title",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: agentPermission,
|
||||
prompt: PROMPT_TITLE,
|
||||
tools: {},
|
||||
},
|
||||
summary: {
|
||||
name: "summary",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
native: true,
|
||||
hidden: true,
|
||||
permission: agentPermission,
|
||||
prompt: PROMPT_SUMMARY,
|
||||
tools: {},
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
|
|
@ -165,7 +185,7 @@ export namespace Agent {
|
|||
...defaultTools,
|
||||
},
|
||||
mode: "primary",
|
||||
builtIn: true,
|
||||
native: true,
|
||||
},
|
||||
}
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
|
|
@ -181,7 +201,7 @@ export namespace Agent {
|
|||
permission: agentPermission,
|
||||
options: {},
|
||||
tools: {},
|
||||
builtIn: false,
|
||||
native: false,
|
||||
}
|
||||
const {
|
||||
name,
|
||||
|
|
|
|||
18
packages/opencode/src/agent/prompt/explore.txt
Normal file
18
packages/opencode/src/agent/prompt/explore.txt
Normal file
|
|
@ -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 user’s tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
|
||||
- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"):
|
||||
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
|
||||
</rules>
|
||||
|
||||
<examples>
|
||||
|
|
@ -85,47 +85,16 @@ export namespace BunProc {
|
|||
version,
|
||||
})
|
||||
|
||||
const total = 3
|
||||
const wait = 500
|
||||
|
||||
const runInstall = async (count: number = 1): Promise<void> => {
|
||||
log.info("bun install attempt", {
|
||||
pkg,
|
||||
version,
|
||||
attempt: count,
|
||||
total,
|
||||
})
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch(async (error) => {
|
||||
log.warn("bun install failed", {
|
||||
pkg,
|
||||
version,
|
||||
attempt: count,
|
||||
total,
|
||||
error,
|
||||
})
|
||||
if (count >= total) {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
)
|
||||
}
|
||||
const delay = wait * count
|
||||
log.info("bun install retrying", {
|
||||
pkg,
|
||||
version,
|
||||
next: count + 1,
|
||||
delay,
|
||||
})
|
||||
await Bun.sleep(delay)
|
||||
return runInstall(count + 1)
|
||||
})
|
||||
}
|
||||
|
||||
await runInstall()
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ function App() {
|
|||
let continued = false
|
||||
createEffect(() => {
|
||||
if (continued || sync.status !== "complete" || !args.continue) return
|
||||
const match = sync.data.session.at(0)?.id
|
||||
const match = sync.data.session
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.find((x) => x.parentID === undefined)?.id
|
||||
if (match) {
|
||||
continued = true
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
|
|
|
|||
|
|
@ -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,8 +873,7 @@ export function Prompt(props: PromptProps) {
|
|||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
// when the background is transparent, don't draw the vertical line
|
||||
vertical: theme.background.a != 0 ? "╹" : " ",
|
||||
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
|
|
@ -878,7 +881,7 @@ export function Prompt(props: PromptProps) {
|
|||
border={["bottom"]}
|
||||
borderColor={theme.backgroundElement}
|
||||
customBorderChars={
|
||||
theme.background.a != 0
|
||||
theme.backgroundElement.a !== 0
|
||||
? {
|
||||
...EmptyBorder,
|
||||
horizontal: "▀",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -783,6 +783,7 @@ export namespace Config {
|
|||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Tools that should only be available to primary agents."),
|
||||
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -284,3 +284,12 @@ export const latexindent: Info = {
|
|||
return Bun.which("latexindent") !== null
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return Bun.which("gleam") !== null
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
|||
".gitrebase": "git-rebase",
|
||||
".go": "go",
|
||||
".groovy": "groovy",
|
||||
".gleam": "gleam",
|
||||
".hbs": "handlebars",
|
||||
".handlebars": "handlebars",
|
||||
".hs": "haskell",
|
||||
|
|
|
|||
|
|
@ -1470,4 +1470,61 @@ export namespace LSPServer {
|
|||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const DockerfileLS: Info = {
|
||||
id: "dockerfile",
|
||||
extensions: [".dockerfile", "Dockerfile"],
|
||||
root: async () => Instance.directory,
|
||||
async spawn(root) {
|
||||
let binary = Bun.which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
})
|
||||
return {
|
||||
process: proc,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const Gleam: Info = {
|
||||
id: "gleam",
|
||||
extensions: [".gleam"],
|
||||
root: NearestRoot(["gleam.toml"]),
|
||||
async spawn(root) {
|
||||
const gleam = Bun.which("gleam")
|
||||
if (!gleam) {
|
||||
log.info("gleam not found, please install gleam first")
|
||||
return
|
||||
}
|
||||
return {
|
||||
process: spawn(gleam, ["lsp"], {
|
||||
cwd: root,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -411,6 +411,7 @@ export namespace Provider {
|
|||
status: z.enum(["alpha", "beta", "deprecated", "active"]),
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
release_date: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Model",
|
||||
|
|
@ -489,6 +490,7 @@ export namespace Provider {
|
|||
},
|
||||
interleaved: model.interleaved ?? false,
|
||||
},
|
||||
release_date: model.release_date,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -621,6 +623,8 @@ export namespace Provider {
|
|||
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
|
||||
},
|
||||
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
|
||||
family: model.family ?? existingModel?.family ?? "",
|
||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||
}
|
||||
parsed.models[modelID] = parsedModel
|
||||
}
|
||||
|
|
@ -877,7 +881,7 @@ export namespace Provider {
|
|||
return info
|
||||
}
|
||||
|
||||
export async function getLanguage(model: Model) {
|
||||
export async function getLanguage(model: Model): Promise<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)
|
||||
|
|
@ -199,14 +213,29 @@ export namespace ProviderTransform {
|
|||
}
|
||||
|
||||
export function temperature(model: Provider.Model) {
|
||||
if (model.api.id.toLowerCase().includes("qwen")) return 0.55
|
||||
if (model.api.id.toLowerCase().includes("claude")) return undefined
|
||||
if (model.api.id.toLowerCase().includes("gemini-3-pro")) return 1.0
|
||||
return 0
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("qwen")) return 0.55
|
||||
if (id.includes("claude")) return undefined
|
||||
if (id.includes("gemini-3-pro")) return 1.0
|
||||
if (id.includes("glm-4.6")) return 1.0
|
||||
if (id.includes("minimax-m2")) return 1.0
|
||||
// if (id.includes("kimi-k2")) {
|
||||
// if (id.includes("thinking")) return 1.0
|
||||
// return 0.6
|
||||
// }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function topP(model: Provider.Model) {
|
||||
if (model.api.id.toLowerCase().includes("qwen")) return 1
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("qwen")) return 1
|
||||
if (id.includes("minimax-m2")) return 0.95
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function topK(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
if (id.includes("minimax-m2")) return 40
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
packages/opencode/src/session/llm.ts
Normal file
190
packages/opencode/src/session/llm.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,29 +472,6 @@ export namespace SessionPrompt {
|
|||
tools: lastUser.tools,
|
||||
processor,
|
||||
})
|
||||
const provider = await Provider.getProvider(model.providerID)
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
{
|
||||
sessionID: sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: model,
|
||||
provider,
|
||||
message: lastUser,
|
||||
},
|
||||
{
|
||||
temperature: model.capabilities.temperature
|
||||
? (agent.temperature ?? ProviderTransform.temperature(model))
|
||||
: undefined,
|
||||
topP: agent.topP ?? ProviderTransform.topP(model),
|
||||
options: pipe(
|
||||
{},
|
||||
mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)),
|
||||
mergeDeep(model.options),
|
||||
mergeDeep(agent.options),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if (step === 1) {
|
||||
SessionSummary.summarize({
|
||||
|
|
@ -522,134 +480,29 @@ export namespace SessionPrompt {
|
|||
})
|
||||
}
|
||||
|
||||
// Deep copy message history so that modifications made by plugins do not
|
||||
// affect the original messages
|
||||
const sessionMessages = clone(
|
||||
msgs.filter((m) => {
|
||||
if (m.info.role !== "assistant" || m.info.error === undefined) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
MessageV2.AbortedError.isInstance(m.info.error) &&
|
||||
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}),
|
||||
)
|
||||
const sessionMessages = clone(msgs)
|
||||
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })
|
||||
|
||||
const messages: ModelMessage[] = [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...MessageV2.toModelMessage(sessionMessages),
|
||||
...(isLastStep
|
||||
? [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: MAX_STEPS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
const result = await processor.process({
|
||||
onError(error) {
|
||||
log.error("stream error", {
|
||||
error,
|
||||
})
|
||||
},
|
||||
async experimental_repairToolCall(input) {
|
||||
const lower = input.toolCall.toolName.toLowerCase()
|
||||
if (lower !== input.toolCall.toolName && tools[lower]) {
|
||||
log.info("repairing tool call", {
|
||||
tool: input.toolCall.toolName,
|
||||
repaired: lower,
|
||||
})
|
||||
return {
|
||||
...input.toolCall,
|
||||
toolName: lower,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...input.toolCall,
|
||||
input: JSON.stringify({
|
||||
tool: input.toolCall.toolName,
|
||||
error: input.error.message,
|
||||
}),
|
||||
toolName: "invalid",
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
...(model.providerID.startsWith("opencode")
|
||||
? {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-request": lastUser.id,
|
||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||
}
|
||||
: undefined),
|
||||
...model.headers,
|
||||
},
|
||||
// set to 0, we handle loop
|
||||
maxRetries: 0,
|
||||
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(
|
||||
model.api.npm,
|
||||
params.options,
|
||||
model.limit.output,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
),
|
||||
abortSignal: abort,
|
||||
providerOptions: ProviderTransform.providerOptions(model, params.options),
|
||||
stopWhen: stepCountIs(1),
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
messages,
|
||||
tools: model.capabilities.toolcall === false ? undefined : tools,
|
||||
model: wrapLanguageModel({
|
||||
model: language,
|
||||
middleware: [
|
||||
{
|
||||
async transformParams(args) {
|
||||
if (args.type === "stream") {
|
||||
// @ts-expect-error - prompt types are compatible at runtime
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, model)
|
||||
}
|
||||
// Transform tool schemas for provider compatibility
|
||||
if (args.params.tools && Array.isArray(args.params.tools)) {
|
||||
args.params.tools = args.params.tools.map((tool: any) => {
|
||||
// Tools at middleware level have inputSchema, not parameters
|
||||
if (tool.inputSchema && typeof tool.inputSchema === "object") {
|
||||
// Transform the inputSchema for provider compatibility
|
||||
return {
|
||||
...tool,
|
||||
inputSchema: ProviderTransform.schema(model, tool.inputSchema),
|
||||
}
|
||||
}
|
||||
// If no inputSchema, return tool unchanged
|
||||
return tool
|
||||
})
|
||||
}
|
||||
return args.params
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
sessionId: sessionID,
|
||||
},
|
||||
},
|
||||
user: lastUser,
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessage(sessionMessages),
|
||||
...(isLastStep
|
||||
? [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: MAX_STEPS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
tools,
|
||||
model,
|
||||
})
|
||||
if (result === "stop") break
|
||||
continue
|
||||
|
|
@ -673,33 +526,6 @@ export namespace SessionPrompt {
|
|||
return Provider.defaultModel()
|
||||
}
|
||||
|
||||
async function resolveSystemPrompt(input: {
|
||||
system?: string
|
||||
agent: Agent.Info
|
||||
model: Provider.Model
|
||||
isLastStep?: boolean
|
||||
}) {
|
||||
let system = SystemPrompt.header(input.model.providerID)
|
||||
system.push(
|
||||
...(() => {
|
||||
if (input.system) return [input.system]
|
||||
if (input.agent.prompt) return [input.agent.prompt]
|
||||
return SystemPrompt.provider(input.model)
|
||||
})(),
|
||||
)
|
||||
system.push(...(await SystemPrompt.environment()))
|
||||
system.push(...(await SystemPrompt.custom()))
|
||||
|
||||
if (input.isLastStep) {
|
||||
system.push(MAX_STEPS)
|
||||
}
|
||||
|
||||
// max 2 system prompt messages for caching purposes
|
||||
const [first, ...rest] = system
|
||||
system = [first, rest.join("\n")]
|
||||
return system
|
||||
}
|
||||
|
||||
async function resolveTools(input: {
|
||||
agent: Agent.Info
|
||||
model: Provider.Model
|
||||
|
|
@ -707,6 +533,7 @@ export namespace SessionPrompt {
|
|||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
const enabledTools = pipe(
|
||||
input.agent.tools,
|
||||
|
|
@ -776,7 +603,6 @@ export namespace SessionPrompt {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(await MCP.tools())) {
|
||||
if (Wildcard.all(key, enabledTools) === false) continue
|
||||
const execute = item.execute
|
||||
|
|
@ -855,7 +681,6 @@ export namespace SessionPrompt {
|
|||
created: Date.now(),
|
||||
},
|
||||
tools: input.tools,
|
||||
system: input.system,
|
||||
agent: agent.name,
|
||||
model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
|
||||
}
|
||||
|
|
@ -1146,7 +971,7 @@ export namespace SessionPrompt {
|
|||
synthetic: true,
|
||||
})
|
||||
}
|
||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan")
|
||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
||||
if (wasPlan && input.agent.name === "build") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
|
|
@ -1214,6 +1039,7 @@ export namespace SessionPrompt {
|
|||
sessionID: input.sessionID,
|
||||
parentID: userMsg.id,
|
||||
mode: input.agent,
|
||||
agent: input.agent,
|
||||
cost: 0,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
|
|
@ -1508,28 +1334,24 @@ export namespace SessionPrompt {
|
|||
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
|
||||
.length === 1
|
||||
if (!isFirst) return
|
||||
const cfg = await Config.get()
|
||||
const small =
|
||||
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
|
||||
const language = await Provider.getLanguage(small)
|
||||
const provider = await Provider.getProvider(small.providerID)
|
||||
const options = pipe(
|
||||
{},
|
||||
mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)),
|
||||
mergeDeep(ProviderTransform.smallOptions(small)),
|
||||
mergeDeep(small.options),
|
||||
)
|
||||
await generateText({
|
||||
// use higher # for reasoning models since reasoning tokens eat up a lot of the budget
|
||||
maxOutputTokens: small.capabilities.reasoning ? 3000 : 20,
|
||||
providerOptions: ProviderTransform.providerOptions(small, options),
|
||||
const agent = await Agent.get("title")
|
||||
if (!agent) return
|
||||
const result = await LLM.stream({
|
||||
agent,
|
||||
user: input.message.info as MessageV2.User,
|
||||
system: [],
|
||||
small: true,
|
||||
tools: {},
|
||||
model: await iife(async () => {
|
||||
if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
return (
|
||||
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
|
||||
)
|
||||
}),
|
||||
abort: new AbortController().signal,
|
||||
sessionID: input.session.id,
|
||||
retries: 2,
|
||||
messages: [
|
||||
...SystemPrompt.title(small.providerID).map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
{
|
||||
role: "user",
|
||||
content: "Generate a title for this conversation:\n",
|
||||
|
|
@ -1553,32 +1375,19 @@ export namespace SessionPrompt {
|
|||
},
|
||||
]),
|
||||
],
|
||||
headers: small.headers,
|
||||
model: language,
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
sessionId: input.session.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.text)
|
||||
return Session.update(input.session.id, (draft) => {
|
||||
const cleaned = result.text
|
||||
.replace(/<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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@ export namespace SessionRetry {
|
|||
if (json.code === "Some resource has been exhausted") {
|
||||
return "Provider is overloaded"
|
||||
}
|
||||
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
|
||||
return "Rate Limited"
|
||||
}
|
||||
if (
|
||||
json.error?.message?.includes("no_kv_space") ||
|
||||
(json.type === "error" && json.error?.type === "server_error")
|
||||
) {
|
||||
return "Provider Server Error"
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { Instance } from "../project/instance"
|
|||
import { Agent } from "../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
}
|
||||
|
|
@ -74,7 +76,7 @@ export const EditTool = Tool.define("edit", {
|
|||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await (async () => {
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
|
|
@ -95,6 +97,7 @@ export const EditTool = Tool.define("edit", {
|
|||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +134,8 @@ export const EditTool = Tool.define("edit", {
|
|||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
})()
|
||||
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
let output = ""
|
||||
await LSP.touchFile(filePath, true)
|
||||
|
|
@ -141,10 +143,11 @@ export const EditTool = Tool.define("edit", {
|
|||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
if (issues.length === 0) continue
|
||||
if (file === filePath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues
|
||||
.filter((item) => item.severity === 1)
|
||||
.map(LSP.Diagnostic.pretty)
|
||||
.join("\n")}\n</file_diagnostics>\n`
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import { Filesystem } from "../util/filesystem"
|
|||
import { Instance } from "../project/instance"
|
||||
import { Agent } from "../agent/agent"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
export const WriteTool = Tool.define("write", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
|
|
@ -77,13 +80,20 @@ export const WriteTool = Tool.define("write", {
|
|||
let output = ""
|
||||
await LSP.touchFile(filepath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
if (issues.length === 0) continue
|
||||
const sorted = issues.toSorted((a, b) => (a.severity ?? 4) - (b.severity ?? 4))
|
||||
const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
if (file === filepath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
|
||||
continue
|
||||
}
|
||||
output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n<project_diagnostics>\n${file}\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</project_diagnostics>\n`
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
|
|
|||
|
|
@ -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,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1518,6 +1519,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 +1662,7 @@ export type Model = {
|
|||
headers: {
|
||||
[key: string]: string
|
||||
}
|
||||
release_date: string
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
|
|
@ -1734,7 +1740,8 @@ export type Agent = {
|
|||
name: string
|
||||
description?: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
builtIn: boolean
|
||||
native?: boolean
|
||||
hidden?: boolean
|
||||
topP?: number
|
||||
temperature?: number
|
||||
color?: string
|
||||
|
|
@ -2797,10 +2804,10 @@ export type SessionPromptData = {
|
|||
}
|
||||
agent?: string
|
||||
noReply?: boolean
|
||||
system?: string
|
||||
tools?: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
system?: string
|
||||
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||
}
|
||||
path: {
|
||||
|
|
@ -2892,10 +2899,10 @@ export type SessionPromptAsyncData = {
|
|||
}
|
||||
agent?: string
|
||||
noReply?: boolean
|
||||
system?: string
|
||||
tools?: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
system?: string
|
||||
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||
}
|
||||
path: {
|
||||
|
|
|
|||
|
|
@ -1997,9 +1997,6 @@
|
|||
"noReply": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
|
|
@ -2009,6 +2006,9 @@
|
|||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
@ -2202,9 +2202,6 @@
|
|||
"noReply": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
|
|
@ -2214,6 +2211,9 @@
|
|||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"type": "string"
|
||||
},
|
||||
"parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
@ -5193,6 +5193,9 @@
|
|||
"mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -5251,6 +5254,7 @@
|
|||
"modelID",
|
||||
"providerID",
|
||||
"mode",
|
||||
"agent",
|
||||
"path",
|
||||
"cost",
|
||||
"tokens"
|
||||
|
|
@ -6152,6 +6156,72 @@
|
|||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Brief description of the task",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"description": "Priority level of the task: high, medium, low",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Unique identifier for the todo item",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["content", "status", "priority", "id"]
|
||||
},
|
||||
"Event.todo.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "todo.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Todo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "todos"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"SessionStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -6255,72 +6325,6 @@
|
|||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Brief description of the task",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"description": "Priority level of the task: high, medium, low",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Unique identifier for the todo item",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["content", "status", "priority", "id"]
|
||||
},
|
||||
"Event.todo.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "todo.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Todo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "todos"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.command.executed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -6886,6 +6890,12 @@
|
|||
{
|
||||
"$ref": "#/components/schemas/Event.permission.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
|
|
@ -6895,12 +6905,6 @@
|
|||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.command.executed"
|
||||
},
|
||||
|
|
@ -8279,6 +8283,10 @@
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"continue_loop_on_deny": {
|
||||
"description": "Continue the agent loop when a tool call is denied",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8673,9 +8681,24 @@
|
|||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"release_date": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "providerID", "api", "name", "capabilities", "cost", "limit", "status", "options", "headers"]
|
||||
"required": [
|
||||
"id",
|
||||
"providerID",
|
||||
"api",
|
||||
"name",
|
||||
"capabilities",
|
||||
"cost",
|
||||
"limit",
|
||||
"status",
|
||||
"options",
|
||||
"headers",
|
||||
"release_date"
|
||||
]
|
||||
},
|
||||
"Provider": {
|
||||
"type": "object",
|
||||
|
|
@ -8916,7 +8939,10 @@
|
|||
"type": "string",
|
||||
"enum": ["subagent", "primary", "all"]
|
||||
},
|
||||
"builtIn": {
|
||||
"native": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"topP": {
|
||||
|
|
@ -8997,7 +9023,7 @@
|
|||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"required": ["name", "mode", "builtIn", "permission", "tools", "options"]
|
||||
"required": ["name", "mode", "permission", "tools", "options"]
|
||||
},
|
||||
"MCPStatusConnected": {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/tauri",
|
||||
"private": true,
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenCode",
|
||||
"mainBinaryName": "OpenCode Desktop",
|
||||
"mainBinaryName": "OpenCode",
|
||||
"version": "../package.json",
|
||||
"identifier": "ai.opencode.desktop",
|
||||
"build": {
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "nsis"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"externalBin": ["sidecars/opencode"],
|
||||
"externalBin": ["sidecars/opencode-cli"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"macOS": {
|
||||
"entitlements": "./entitlements.plist"
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
|
||||
"endpoints": ["https://github.com/brendonovich/opencode/releases/latest/download/latest.json"]
|
||||
"endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.152",
|
||||
"version": "1.0.153",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ export type TriggerTitle = {
|
|||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
|
||||
return (
|
||||
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
|
||||
)
|
||||
}
|
||||
|
||||
export interface BasicToolProps {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
user-select: none;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
|
|
@ -148,7 +149,7 @@
|
|||
padding: 0 12px 0 8px;
|
||||
}
|
||||
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">"{filter()}"</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">"{filter()}"</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,7 +321,6 @@ ToolRegistry.register({
|
|||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
defaultOpen
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
[data-component="session-turn"] {
|
||||
/* flex: 1; */
|
||||
--scroll-y: 0px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
|
@ -26,18 +27,26 @@
|
|||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
gap: 32px;
|
||||
gap: clamp(8px, calc(42px - var(--scroll-y) * 0.48), 42px);
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-sticky-header"] {
|
||||
[data-slot="session-turn-sticky-title"] {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-response-trigger"] {
|
||||
position: sticky;
|
||||
top: 32px;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: calc(100% + 9px);
|
||||
margin-left: -9px;
|
||||
padding-left: 9px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
|
|
@ -49,13 +58,8 @@
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
/* [data-slot="session-turn-message-content"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="session-turn-response-trigger"] {
|
||||
width: calc(100% + 9px);
|
||||
margin-left: -9px;
|
||||
padding-left: 9px;
|
||||
[data-slot="session-turn-message-content"] {
|
||||
margin-top: -24px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-title"] {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,7 @@ import { useData } from "../context"
|
|||
import { useDiffComponent } from "../context/diff"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentProps,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
|
|
@ -60,45 +49,71 @@ export function SessionTurn(
|
|||
const working = createMemo(() => status()?.type !== "idle")
|
||||
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
|
||||
const [userScrolled, setUserScrolled] = createSignal(false)
|
||||
const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
|
||||
const [state, setState] = createStore({
|
||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||
userScrolled: false,
|
||||
stickyHeaderHeight: 0,
|
||||
scrollY: 0,
|
||||
autoScrolling: false,
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef) return
|
||||
// prevents scroll loops
|
||||
if (working() && scrollRef.scrollTop < 100) return
|
||||
setState("scrollY", scrollRef.scrollTop)
|
||||
if (state.autoScrolling) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
if (!atBottom && working()) {
|
||||
setUserScrolled(true)
|
||||
setState("userScrolled", true)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
if (working()) {
|
||||
setUserScrolled(true)
|
||||
setState("userScrolled", true)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
|
||||
setState("autoScrolling", true)
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
|
||||
requestAnimationFrame(() => {
|
||||
setState("autoScrolling", false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!working()) {
|
||||
setUserScrolled(false)
|
||||
setState("userScrolled", false)
|
||||
}
|
||||
})
|
||||
|
||||
createResizeObserver(contentRef, () => {
|
||||
if (!scrollRef || userScrolled() || !working()) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
})
|
||||
createResizeObserver(
|
||||
() => state.stickyTitleRef,
|
||||
({ height }) => {
|
||||
const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createResizeObserver(stickyHeaderRef, ({ height }) => {
|
||||
setStickyHeaderHeight(height + 8)
|
||||
})
|
||||
createResizeObserver(
|
||||
() => state.stickyTriggerRef,
|
||||
({ height }) => {
|
||||
const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
|
||||
setState("stickyHeaderHeight", titleHeight + height + 8)
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<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={setContentRef} onClick={handleInteraction}>
|
||||
<div onClick={handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(message) => {
|
||||
const assistantMessages = createMemo(() => {
|
||||
|
|
@ -175,6 +190,9 @@ export function SessionTurn(
|
|||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
const text = last.text ?? ""
|
||||
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
|
||||
if (match) return `Thinking · ${match[1].trim()}`
|
||||
return "Thinking"
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts"
|
||||
|
|
@ -197,6 +215,11 @@ export function SessionTurn(
|
|||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
lastPart()
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
status: rawStatus(),
|
||||
stepsExpanded: true,
|
||||
|
|
@ -237,7 +260,7 @@ export function SessionTurn(
|
|||
|
||||
createEffect((prev) => {
|
||||
const isWorking = working()
|
||||
if (prev && !isWorking && !userScrolled()) {
|
||||
if (prev && !isWorking && !state.userScrolled) {
|
||||
setStore("stepsExpanded", false)
|
||||
}
|
||||
return isWorking
|
||||
|
|
@ -248,10 +271,10 @@ export function SessionTurn(
|
|||
data-message={message().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
|
||||
style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }}
|
||||
>
|
||||
{/* Sticky Header */}
|
||||
<div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
|
||||
{/* Title (sticky) */}
|
||||
<div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
|
|
@ -264,29 +287,31 @@ export function SessionTurn(
|
|||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
<div data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
|
||||
<Match when={store.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!store.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* User Message */}
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={message()} parts={parts()} />
|
||||
</div>
|
||||
{/* Trigger (sticky) */}
|
||||
<div ref={(el) => setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={store.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!store.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<Show when={store.stepsExpanded}>
|
||||
|
|
|
|||
131
packages/ui/src/components/switch.css
Normal file
131
packages/ui/src/components/switch.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue