Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6a3be31de | |||
| d16c8c9f0f | |||
| fffe20cbe5 | |||
| f6da3c467b | |||
| c0d9f21c0f | |||
| a67b616139 | |||
| 2991547974 | |||
| b59def2e4a | |||
| d842353f39 | |||
| d20ef569de | |||
| ca2b871810 | |||
| 23ea8ba1ce | |||
| c417fec246 | |||
| 5413b16b57 | |||
| 43c021ed80 | |||
| 3b005d29d7 | |||
| 635f70f477 | |||
| adbb6037ac | |||
| 598d6d00e4 | |||
| cf934357c9 | |||
| 8063e645c7 | |||
| 8ab206b443 | |||
| ec5c96e10d | |||
| d2a61290b9 | |||
| 10faf9e717 | |||
| cba239bc8f | |||
| 6f5e3ddfb3 | |||
| d412ba264a | |||
| 9780f2b792 | |||
| 5aa2078852 | |||
| eb975bb89c | |||
| 9479fe3ce6 | |||
| 4393cf8dbe | |||
| 715265de4b | |||
| 2048f32491 | |||
| 2d7ba43a21 | |||
| bd198d8550 | |||
| cbffbcdd3d | |||
| 2be8b2269f | |||
| c1fa257a92 | |||
| 7fd81dd93e | |||
| d554e7aaef |
@@ -17,3 +17,4 @@ dist
|
||||
refs
|
||||
Session.vim
|
||||
opencode.json
|
||||
a.out
|
||||
|
||||
@@ -87,7 +87,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||
- Out of the box LSP support
|
||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -116,7 +116,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -172,7 +172,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -251,7 +251,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -271,7 +271,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.81.0",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -282,7 +282,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -295,7 +295,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -325,7 +325,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -335,7 +335,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763464769,
|
||||
"narHash": "sha256-AJHrsT7VoeQzErpBRlLJM1SODcaayp0joAoEA35yiwM=",
|
||||
"lastModified": 1763618868,
|
||||
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6f374686605df381de8541c072038472a5ea2e2d",
|
||||
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "1.0.81"
|
||||
"version": "1.0.85"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The AI coding agent built for the terminal"
|
||||
version = "1.0.81"
|
||||
version = "1.0.85"
|
||||
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.81/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/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.81/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.81/opencode-linux-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.81/opencode-linux-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-linux-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.81/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.85/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch } from "solid-js"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { Global } from "@/global"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
@@ -293,6 +294,14 @@ function App() {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Connect provider",
|
||||
value: "provider.connect",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogProviderList />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
|
||||
value: "theme.switch_mode",
|
||||
@@ -451,16 +460,18 @@ function App() {
|
||||
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme.textMuted} paddingRight={1}>
|
||||
tab
|
||||
</text>
|
||||
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
|
||||
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
|
||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={false}>
|
||||
<box flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme.textMuted} paddingRight={1}>
|
||||
tab
|
||||
</text>
|
||||
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
|
||||
<text bg={local.agent.color(local.agent.current().name)} fg={theme.background} wrapMode={undefined}>
|
||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
export const EmptyBorder = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
customBorderChars: {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: "",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
|
||||
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy, take } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
function Free() {
|
||||
const { theme } = useTheme()
|
||||
return <span style={{ fg: theme.secondary }}>Free</span>
|
||||
}
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
|
||||
export function DialogModel() {
|
||||
const local = useLocal()
|
||||
@@ -17,9 +12,16 @@ export function DialogModel() {
|
||||
const dialog = useDialog()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
|
||||
const connected = createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
|
||||
const showRecent = createMemo(() => !ref()?.filter && local.model.recent().length > 0 && connected())
|
||||
const providers = createDialogProviderOptions()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
...(!ref()?.filter
|
||||
...(showRecent()
|
||||
? local.model.recent().flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
|
||||
if (!provider) return []
|
||||
@@ -35,7 +37,17 @@ export function DialogModel() {
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -56,28 +68,56 @@ export function DialogModel() {
|
||||
modelID: model,
|
||||
},
|
||||
title: info.name ?? model,
|
||||
description: provider.name,
|
||||
category: provider.name,
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
|
||||
description: connected() ? provider.name : undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
})),
|
||||
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
filter((x) => !showRecent() || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
sortBy((x) => x.title),
|
||||
),
|
||||
),
|
||||
),
|
||||
...(!connected()
|
||||
? pipe(
|
||||
providers(),
|
||||
map((option) => {
|
||||
return {
|
||||
...option,
|
||||
category: "Popular providers",
|
||||
}
|
||||
}),
|
||||
take(6),
|
||||
)
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
keybind={[
|
||||
{
|
||||
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
|
||||
title: connected() ? "Connect provider" : "More providers",
|
||||
onTrigger() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
ref={setRef}
|
||||
title="Select model"
|
||||
current={local.model.current()}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
local.model.set(option.value, { recent: true })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { map, pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
anthropic: 1,
|
||||
"github-copilot": 2,
|
||||
openai: 3,
|
||||
google: 4,
|
||||
openrouter: 5,
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
map((provider) => ({
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
footer: {
|
||||
opencode: "Recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
}[provider.id],
|
||||
async onSelect() {
|
||||
const methods = sync.data.provider_auth[provider.id] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
path: {
|
||||
id: provider.id,
|
||||
},
|
||||
body: {
|
||||
method: index,
|
||||
},
|
||||
})
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
||||
}
|
||||
},
|
||||
})),
|
||||
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
|
||||
)
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
export function DialogProvider() {
|
||||
const options = createDialogProviderOptions()
|
||||
return <DialogSelect title="Connect a provider" options={options()} />
|
||||
}
|
||||
|
||||
interface AutoMethodProps {
|
||||
index: number
|
||||
providerID: string
|
||||
title: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function AutoMethod(props: AutoMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
|
||||
onMount(async () => {
|
||||
const result = await sdk.client.provider.oauth.callback({
|
||||
path: {
|
||||
id: props.providerID,
|
||||
},
|
||||
body: {
|
||||
method: props.index,
|
||||
},
|
||||
})
|
||||
if (result.error) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel />)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
interface CodeMethodProps {
|
||||
index: number
|
||||
title: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function CodeMethod(props: CodeMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [error, setError] = createSignal(false)
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="Authorization code"
|
||||
onConfirm={async (value) => {
|
||||
const { error } = await sdk.client.provider.oauth.callback({
|
||||
path: {
|
||||
id: props.providerID,
|
||||
},
|
||||
body: {
|
||||
method: props.index,
|
||||
code: value,
|
||||
},
|
||||
})
|
||||
if (!error) {
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel />)
|
||||
return
|
||||
}
|
||||
setError(true)
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<text fg={theme.primary}>{props.authorization.url}</text>
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
sdk.client.auth.set({
|
||||
path: {
|
||||
id: props.providerID,
|
||||
},
|
||||
body: {
|
||||
type: "api",
|
||||
key: value,
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
import {
|
||||
TextAttributes,
|
||||
BoxRenderable,
|
||||
TextareaRenderable,
|
||||
MouseEvent,
|
||||
PasteEvent,
|
||||
t,
|
||||
dim,
|
||||
fg,
|
||||
type KeyBinding,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
|
||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
|
||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { EmptyBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
|
||||
import type { FilePart } from "@opencode-ai/sdk"
|
||||
import { TuiEvent } from "../../event"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Shimmer } from "../../ui/shimmer"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
|
||||
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
|
||||
const history = usePromptHistory()
|
||||
const command = useCommandDialog()
|
||||
const renderer = useRenderer()
|
||||
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
|
||||
title: "Interrupt session",
|
||||
value: "session.interrupt",
|
||||
keybind: "session_interrupt",
|
||||
disabled: status() !== "working",
|
||||
disabled: status().type === "idle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
if (!props.sessionID) return
|
||||
if (autocomplete.visible) return
|
||||
if (!input.focused) return
|
||||
// TODO: this should be its own command
|
||||
if (store.mode === "shell") {
|
||||
setStore("mode", "normal")
|
||||
return
|
||||
}
|
||||
if (!props.sessionID) return
|
||||
|
||||
setStore("interrupt", store.interrupt + 1)
|
||||
|
||||
@@ -542,6 +539,16 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (keybind.leader) return theme.border
|
||||
if (store.mode === "shell") return theme.primary
|
||||
return local.agent.color(local.agent.current().name)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.setCursorColor(highlight())
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
@@ -566,17 +573,22 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
{...SplitBorder}
|
||||
borderColor={keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border}
|
||||
justifyContent="space-evenly"
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box backgroundColor={theme.backgroundElement} width={3} height="100%" alignItems="center" paddingTop={1}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
|
||||
{store.mode === "normal" ? ">" : "!"}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1} backgroundColor={theme.backgroundElement} flexGrow={1}>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
>
|
||||
<textarea
|
||||
placeholder={
|
||||
props.showPlaceholder
|
||||
@@ -751,37 +763,151 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.primary}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<text flexShrink={0} fg={theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
<box backgroundColor={theme.backgroundElement} width={1} justifyContent="center" alignItems="center"></box>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={highlight()}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme.backgroundElement}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrapMode="none" fg={theme.text}>
|
||||
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={status() === "compacting"}>
|
||||
<text fg={theme.textMuted}>compacting...</text>
|
||||
</Match>
|
||||
<Match when={status() === "working"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
<Show when={status().type !== "idle"} fallback={<text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Loader />
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini 3 way too hot right now"
|
||||
if (r.message.length > 50) return r.message.slice(0, 50) + "..."
|
||||
return r.message
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<text fg={theme.error}>
|
||||
{message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
|
||||
attempt #{retry()!.attempt}]
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.hint}>{props.hint!}</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
const FRAMES = [
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▰▱▱▱▱▱▱",
|
||||
"▰▰▱▱▱▱▱",
|
||||
"▰▰▰▱▱▱▱",
|
||||
"▱▰▰▰▱▱▱",
|
||||
"▱▱▰▰▰▱▱",
|
||||
"▱▱▱▰▰▰▱",
|
||||
"▱▱▱▱▰▰▰",
|
||||
"▱▱▱▱▱▰▰",
|
||||
"▱▱▱▱▱▱▰",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
"▱▱▱▱▱▱▱",
|
||||
]
|
||||
const [frame, setFrame] = createSignal(0)
|
||||
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((frame() + 1) % FRAMES.length)
|
||||
}, 100)
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
return <text fg={theme.diffAdded}>{FRAMES[frame()]}</text>
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { useArgs } from "./args"
|
||||
import { RGBA } from "@opentui/core"
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
},
|
||||
color(name: string) {
|
||||
const agent = agents().find((x) => x.name === name)
|
||||
if (agent?.color) return agent.color
|
||||
if (agent?.color) return RGBA.fromHex(agent.color)
|
||||
const index = agents().findIndex((x) => x.name === name)
|
||||
return colors()[index % colors().length]
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
console.log("event", event.type)
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
McpStatus,
|
||||
FormatterStatus,
|
||||
SessionStatus,
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
@@ -28,6 +30,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
status: "loading" | "partial" | "complete"
|
||||
provider: Provider[]
|
||||
provider_default: Record<string, string>
|
||||
provider_next: ProviderListResponse
|
||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: {
|
||||
@@ -56,6 +60,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
default: {},
|
||||
connected: [],
|
||||
},
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
status: "loading",
|
||||
agent: [],
|
||||
@@ -232,20 +242,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
const exit = useExit()
|
||||
|
||||
onMount(() => {
|
||||
async function bootstrap() {
|
||||
// blocking
|
||||
Promise.all([
|
||||
await Promise.all([
|
||||
sdk.client.config.providers({ throwOnError: true }).then((x) => {
|
||||
batch(() => {
|
||||
setStore("provider", x.data!.providers)
|
||||
setStore("provider_default", x.data!.default)
|
||||
})
|
||||
}),
|
||||
sdk.client.provider.list({ throwOnError: true }).then((x) => {
|
||||
batch(() => {
|
||||
setStore("provider_next", x.data!)
|
||||
})
|
||||
}),
|
||||
sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
|
||||
])
|
||||
.then(() => {
|
||||
setStore("status", "partial")
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
// non-blocking
|
||||
Promise.all([
|
||||
sdk.client.session.list().then((x) =>
|
||||
@@ -259,6 +274,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
||||
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -266,6 +282,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.catch(async (e) => {
|
||||
await exit(e)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bootstrap()
|
||||
})
|
||||
|
||||
const result = {
|
||||
@@ -320,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
console.log("synced in " + (Date.now() - now), sessionID)
|
||||
},
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -142,9 +142,30 @@ export function Session() {
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
// Auto-navigate to whichever session currently needs permission input
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return
|
||||
const currentPermissions = permissions()
|
||||
let targetID = currentPermissions.length > 0 ? currentSession.id : undefined
|
||||
|
||||
if (!targetID) {
|
||||
const child = sync.data.session.find(
|
||||
(x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0,
|
||||
)
|
||||
if (child) targetID = child.id
|
||||
}
|
||||
|
||||
if (targetID && targetID !== currentSession.id) {
|
||||
navigate({
|
||||
type: "session",
|
||||
sessionID: targetID,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
const keybind = useKeybind()
|
||||
@@ -580,11 +601,9 @@ export function Session() {
|
||||
}
|
||||
|
||||
// Prompt for optional filename
|
||||
const customFilename = await DialogPrompt.show(
|
||||
dialog,
|
||||
"Export filename",
|
||||
`session-${sessionData.id.slice(0, 8)}.md`,
|
||||
)
|
||||
const customFilename = await DialogPrompt.show(dialog, "Export filename", {
|
||||
value: `session-${sessionData.id.slice(0, 8)}.md`,
|
||||
})
|
||||
|
||||
// Cancel if user pressed escape
|
||||
if (customFilename === null) return
|
||||
@@ -883,52 +902,55 @@ function UserMessage(props: {
|
||||
<Show when={text()}>
|
||||
<box
|
||||
id={props.message.id}
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={color()}
|
||||
flexShrink={0}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return theme.accent
|
||||
if (file.mime === "application/pdf") return theme.primary
|
||||
return theme.secondary
|
||||
})
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
|
||||
>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={props.onMouseUp}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={1}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const bg = createMemo(() => {
|
||||
if (file.mime.startsWith("image/")) return theme.accent
|
||||
if (file.mime === "application/pdf") return theme.primary
|
||||
return theme.secondary
|
||||
})
|
||||
return (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<Show
|
||||
when={queued()}
|
||||
fallback={<span style={{ fg: theme.textMuted }}>{Locale.time(props.message.time.created)}</span>}
|
||||
>
|
||||
<span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={compaction()}>
|
||||
@@ -986,7 +1008,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||
</box>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.last && status().type !== "idle"}>
|
||||
<Match when={props.last && status().type !== "idle" && false}>
|
||||
<box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
|
||||
<text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
|
||||
<Shimmer text={props.message.modelID} color={theme.text} />
|
||||
@@ -1289,7 +1311,10 @@ ToolRegistry.register<typeof WriteTool>({
|
||||
container: "block",
|
||||
render(props) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const lines = createMemo(() => props.input.content?.split("\n") ?? [], [] as string[])
|
||||
const lines = createMemo(
|
||||
() => (typeof props.input.content === "string" ? props.input.content.split("\n") : []),
|
||||
[] as string[],
|
||||
)
|
||||
const code = createMemo(() => {
|
||||
if (!props.input.content) return ""
|
||||
const text = props.input.content
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { onMount, type JSX } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
@@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
dialog.setSize("medium")
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 1)
|
||||
@@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box>
|
||||
<box gap={1}>
|
||||
{props.description}
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
dialog.clear()
|
||||
}}
|
||||
height={3}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder="Enter text"
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
/>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={title}
|
||||
value={value}
|
||||
onConfirm={(value) => resolve(value)}
|
||||
onCancel={() => resolve(null)}
|
||||
/>
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
|
||||
@@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box paddingLeft={3} paddingRight={2}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{props.title}
|
||||
@@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
maxHeight={height()}
|
||||
@@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
{([category, options], index) => (
|
||||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
|
||||
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
@@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
|
||||
const current = createMemo(() => isDeepEqual(option.value, props.current))
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
@@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingLeft={current() ? 1 : 3}
|
||||
paddingRight={3}
|
||||
gap={1}
|
||||
>
|
||||
<Option
|
||||
@@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
footer={option.footer}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
active={active()}
|
||||
current={isDeepEqual(option.value, props.current)}
|
||||
current={current()}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
@@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<For each={props.keybind ?? []}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
@@ -268,7 +271,7 @@ function Option(props: {
|
||||
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
|
||||
marginRight={0.5}
|
||||
>
|
||||
●
|
||||
◆
|
||||
</text>
|
||||
</Show>
|
||||
<text
|
||||
@@ -277,6 +280,7 @@ function Option(props: {
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
paddingLeft={3}
|
||||
>
|
||||
{Locale.truncate(props.title, 62)}
|
||||
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { batch, createContext, Show, useContext, type JSX, type ParentProps } fr
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
@@ -12,10 +14,12 @@ export function Dialog(
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseUp={async () => {
|
||||
if (renderer.getSelection()) return
|
||||
props.onClose?.()
|
||||
}}
|
||||
width={dimensions().width}
|
||||
@@ -29,6 +33,7 @@ export function Dialog(
|
||||
>
|
||||
<box
|
||||
onMouseUp={async (e) => {
|
||||
if (renderer.getSelection()) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
@@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
|
||||
|
||||
export function DialogProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
<box position="absolute">
|
||||
<box
|
||||
position="absolute"
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
{value.stack.at(-1)!.element}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
const editor = process.env["EDITOR"]
|
||||
const editor = process.env["VISUAL"] || process.env["EDITOR"]
|
||||
if (!editor) return
|
||||
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { Config } from "../config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export function FormatError(input: unknown) {
|
||||
if (MCP.Failed.isInstance(input))
|
||||
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
if (Provider.ModelNotFoundError.isInstance(input)) {
|
||||
const { providerID, modelID, suggestions } = input.data
|
||||
return [
|
||||
`Model not found: ${providerID}/${modelID}`,
|
||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||
`Try: \`opencode models\` to list available models`,
|
||||
`Or check your config (opencode.json) provider/model names`,
|
||||
].join("\n")
|
||||
}
|
||||
if (Provider.InitError.isInstance(input)) {
|
||||
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
|
||||
}
|
||||
if (Config.JsonError.isInstance(input)) {
|
||||
return (
|
||||
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
|
||||
)
|
||||
}
|
||||
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
|
||||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.`
|
||||
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
|
||||
}
|
||||
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
||||
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
|
||||
|
||||
@@ -428,8 +428,8 @@ export namespace Config {
|
||||
input_newline: z.string().optional().default("shift+return,ctrl+j").describe("Insert newline in input"),
|
||||
history_previous: z.string().optional().default("up").describe("Previous history item"),
|
||||
history_next: z.string().optional().default("down").describe("Next history item"),
|
||||
session_child_cycle: z.string().optional().default("ctrl+right").describe("Next child session"),
|
||||
session_child_cycle_reverse: z.string().optional().default("ctrl+left").describe("Previous child session"),
|
||||
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
|
||||
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
@@ -480,6 +480,10 @@ export namespace Config {
|
||||
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
|
||||
autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
|
||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
||||
enabled_providers: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
small_model: z
|
||||
.string()
|
||||
@@ -511,6 +515,8 @@ export namespace Config {
|
||||
z.string(),
|
||||
ModelsDev.Provider.partial()
|
||||
.extend({
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
|
||||
options: z
|
||||
.object({
|
||||
|
||||
@@ -51,6 +51,7 @@ export const Instance = {
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
cache.delete(Instance.directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
Log.Default.info("disposing all instances")
|
||||
|
||||
@@ -57,7 +57,7 @@ export namespace State {
|
||||
|
||||
tasks.push(task)
|
||||
}
|
||||
entries.delete(key)
|
||||
entries.clear()
|
||||
await Promise.all(tasks)
|
||||
disposalFinished = true
|
||||
log.info("state disposal completed", { key })
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Plugin } from "../plugin"
|
||||
import { map, filter, pipe, fromEntries, mapValues } from "remeda"
|
||||
import z from "zod"
|
||||
import { fn } from "@/util/fn"
|
||||
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
const state = Instance.state(async () => {
|
||||
const methods = pipe(
|
||||
await Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
return { methods, pending: {} as Record<string, AuthOuathResult> }
|
||||
})
|
||||
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export async function methods() {
|
||||
const s = await state().then((x) => x.methods)
|
||||
return mapValues(s, (x) =>
|
||||
x.methods.map(
|
||||
(y): Method => ({
|
||||
type: y.type,
|
||||
label: y.label,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
method: z.number(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> => {
|
||||
const auth = await state().then((s) => s.methods[input.providerID])
|
||||
const method = auth.methods[input.method]
|
||||
if (method.type === "oauth") {
|
||||
const result = await method.authorize()
|
||||
await state().then((s) => (s.pending[input.providerID] = result))
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const match = await state().then((s) => s.pending[input.providerID])
|
||||
if (!match) throw new OauthMissing({ providerID: input.providerID })
|
||||
let result
|
||||
|
||||
if (match.method === "code") {
|
||||
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
|
||||
result = await match.callback(input.code)
|
||||
}
|
||||
|
||||
if (match.method === "auto") {
|
||||
result = await match.callback()
|
||||
}
|
||||
|
||||
if (result?.type === "success") {
|
||||
if ("key" in result) {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
if ("refresh" in result) {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new OauthCallbackFailed({})
|
||||
},
|
||||
)
|
||||
|
||||
export const api = fn(
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
key: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: input.key,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
)
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
@@ -10,7 +10,6 @@ import { ModelsDev } from "./models"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Auth } from "../auth"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Global } from "../global"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
@@ -243,6 +242,15 @@ export namespace Provider {
|
||||
const config = await Config.get()
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
|
||||
|
||||
function isProviderAllowed(providerID: string): boolean {
|
||||
if (enabled && !enabled.has(providerID)) return false
|
||||
if (disabled.has(providerID)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const providers: {
|
||||
[providerID: string]: {
|
||||
source: Source
|
||||
@@ -367,10 +375,10 @@ export namespace Provider {
|
||||
}
|
||||
parsed.models[modelID] = parsedModel
|
||||
}
|
||||
|
||||
database[providerID] = parsed
|
||||
}
|
||||
|
||||
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
|
||||
// load env
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
@@ -448,6 +456,12 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
for (const [providerID, provider] of Object.entries(providers)) {
|
||||
if (!isProviderAllowed(providerID)) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
|
||||
const configProvider = config.provider?.[providerID]
|
||||
const filteredModels = Object.fromEntries(
|
||||
Object.entries(provider.info.models)
|
||||
// Filter out blacklisted models
|
||||
@@ -460,15 +474,32 @@ export namespace Provider {
|
||||
([, model]) =>
|
||||
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
|
||||
model.status !== "deprecated",
|
||||
),
|
||||
)
|
||||
// Filter by provider's whitelist/blacklist from config
|
||||
.filter(([modelID]) => {
|
||||
if (!configProvider) return true
|
||||
|
||||
return (
|
||||
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
|
||||
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
provider.info.models = filteredModels
|
||||
|
||||
if (Object.keys(provider.info.models).length === 0) {
|
||||
delete providers[providerID]
|
||||
continue
|
||||
}
|
||||
log.info("found", { providerID })
|
||||
|
||||
// TODO: set this in models.dev, not set due to breaking issues on older OC versions
|
||||
// u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions
|
||||
if (providerID === "openrouter") {
|
||||
provider.info.npm = "@openrouter/ai-sdk-provider"
|
||||
}
|
||||
|
||||
log.info("found", { providerID, npm: provider.info.npm })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -567,9 +598,78 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) throw new ModelNotFoundError({ providerID, modelID })
|
||||
if (!provider) {
|
||||
let suggestions: string[] = []
|
||||
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
const levenshtein = (a: string, b: string) => {
|
||||
const m = a.length,
|
||||
n = b.length
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0))
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return dp[m][n]
|
||||
}
|
||||
if (!modelID || modelID.trim() === "") {
|
||||
// Treat single-token input as an unqualified model; search across all providers' models.
|
||||
const q = normalize(providerID)
|
||||
const entries: { combo: string; norm: string }[] = []
|
||||
for (const [pid, prov] of Object.entries(s.providers)) {
|
||||
for (const mid of Object.keys(prov.info.models)) {
|
||||
entries.push({ combo: pid + "/" + mid, norm: normalize(mid) })
|
||||
}
|
||||
}
|
||||
const byNorm = fuzzysort.go(q, entries as any, { limit: 5, key: "norm" }).map((r: any) => r.obj.combo)
|
||||
const combos = entries.map((e) => e.combo)
|
||||
const byRaw = fuzzysort.go(providerID, combos, { limit: 5 }).map((r) => r.target)
|
||||
let merged = Array.from(new Set([...byNorm, ...byRaw]))
|
||||
if (merged.length === 0) {
|
||||
// fallback to edit distance on normalized mid
|
||||
const scored = entries
|
||||
.map((e) => ({ combo: e.combo, d: levenshtein(q, e.norm) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, 3)
|
||||
.map((x) => x.combo)
|
||||
merged = scored
|
||||
}
|
||||
suggestions = merged.slice(0, 3)
|
||||
} else {
|
||||
const pcands = Object.keys(s.providers)
|
||||
const corpus = pcands.map((raw) => ({ raw, norm: normalize(raw) }))
|
||||
const q = normalize(providerID)
|
||||
const hits = fuzzysort.go(q, corpus as any, { limit: 5, key: "norm" })
|
||||
let ranked = hits.map((r: any) => r.obj.raw)
|
||||
if (ranked.length === 0) {
|
||||
ranked = pcands
|
||||
.map((p) => ({ p, d: levenshtein(q, normalize(p)) }))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, 3)
|
||||
.map((x) => x.p)
|
||||
}
|
||||
const providerSuggestions = ranked.map((r) => r + "/" + modelID)
|
||||
suggestions = providerSuggestions
|
||||
}
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
||||
}
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError({ providerID, modelID })
|
||||
if (!info) {
|
||||
const candidates = Object.keys(provider.info.models)
|
||||
// Normalize punctuation differences like '-' vs '.' by stripping non-alphanumerics
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
const corpus = candidates.map((raw) => ({ raw, norm: normalize(raw) }))
|
||||
const query = normalize(modelID)
|
||||
const results = fuzzysort.go(query, corpus as any, { limit: 5, key: "norm" })
|
||||
const ranked = results.map((r) => ("obj" in r ? (r as any).obj.raw : (r as any).target)) as string[]
|
||||
const fallback = fuzzysort.go(modelID, candidates, { limit: 5 }).map((r) => r.target)
|
||||
const merged = Array.from(new Set([...ranked, ...fallback]))
|
||||
const suggestions = merged.slice(0, 3).map((m) => providerID + "/" + m)
|
||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
||||
}
|
||||
const sdk = await getSDK(provider.info, info)
|
||||
|
||||
try {
|
||||
@@ -670,6 +770,7 @@ export namespace Provider {
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
modelID: z.string(),
|
||||
suggestions: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export namespace ProviderTransform {
|
||||
result["promptCacheKey"] = sessionID
|
||||
}
|
||||
|
||||
if (providerID === "google") {
|
||||
if (providerID === "google" || (providerID === "opencode" && modelID.includes("gemini-3"))) {
|
||||
result["thinkingConfig"] = {
|
||||
includeThoughts: true,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Instance } from "../project/instance"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Auth } from "../auth"
|
||||
import { Command } from "../command"
|
||||
import { ProviderAuth } from "../provider/auth"
|
||||
import { Global } from "../global"
|
||||
import { ProjectRoute } from "./project"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
@@ -306,6 +307,27 @@ export namespace Server {
|
||||
)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/instance/dispose",
|
||||
describeRoute({
|
||||
description: "Dispose the current instance",
|
||||
operationId: "instance.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Instance disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Instance.dispose()
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/path",
|
||||
describeRoute({
|
||||
@@ -1163,6 +1185,138 @@ export namespace Server {
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/provider",
|
||||
describeRoute({
|
||||
description: "List all providers",
|
||||
operationId: "provider.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
all: ModelsDev.Provider.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
connected: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const providers = await ModelsDev.get()
|
||||
const connected = await Provider.list().then((x) => Object.keys(x))
|
||||
return c.json({
|
||||
all: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
connected,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/provider/auth",
|
||||
describeRoute({
|
||||
description: "Get provider authentication methods",
|
||||
operationId: "provider.auth",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Provider auth methods",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await ProviderAuth.methods())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/provider/:id/oauth/authorize",
|
||||
describeRoute({
|
||||
description: "Authorize a provider using OAuth",
|
||||
operationId: "provider.oauth.authorize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authorization URL and method",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ProviderAuth.Authorization.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
method: z.number().meta({ description: "Auth method index" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const id = c.req.valid("param").id
|
||||
const { method } = c.req.valid("json")
|
||||
const result = await ProviderAuth.authorize({
|
||||
providerID: id,
|
||||
method,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/provider/:id/oauth/callback",
|
||||
describeRoute({
|
||||
description: "Handle OAuth callback for a provider",
|
||||
operationId: "provider.oauth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth callback processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
method: z.number().meta({ description: "Auth method index" }),
|
||||
code: z.string().optional().meta({ description: "OAuth authorization code" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const id = c.req.valid("param").id
|
||||
const { method, code } = c.req.valid("json")
|
||||
await ProviderAuth.callback({
|
||||
providerID: id,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
|
||||
@@ -58,6 +58,7 @@ export namespace MessageV2 {
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
synthetic: z.boolean().optional(),
|
||||
ignored: z.boolean().optional(),
|
||||
time: z
|
||||
.object({
|
||||
start: z.number(),
|
||||
@@ -566,7 +567,7 @@ export namespace MessageV2 {
|
||||
}
|
||||
result.push(userMessage)
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text")
|
||||
if (part.type === "text" && !part.ignored)
|
||||
userMessage.parts.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
|
||||
@@ -49,6 +49,9 @@ import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { SessionStatus } from "./status"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
export namespace SessionPrompt {
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
export const OUTPUT_TOKEN_MAX = 32_000
|
||||
@@ -239,6 +242,7 @@ export namespace SessionPrompt {
|
||||
|
||||
let step = 0
|
||||
while (true) {
|
||||
SessionStatus.set(sessionID, { type: "busy" })
|
||||
log.info("loop", { step, sessionID })
|
||||
if (abort.aborted) break
|
||||
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -24,4 +24,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-108
@@ -26,120 +26,122 @@ export type PluginInput = {
|
||||
|
||||
export type Plugin = (input: PluginInput) => Promise<Hooks>
|
||||
|
||||
export type AuthHook = {
|
||||
provider: string
|
||||
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
|
||||
methods: (
|
||||
| {
|
||||
type: "oauth"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
>
|
||||
authorize(inputs?: Record<string, string>): Promise<AuthOuathResult>
|
||||
}
|
||||
| {
|
||||
type: "api"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
>
|
||||
authorize?(inputs?: Record<string, string>): Promise<
|
||||
| {
|
||||
type: "success"
|
||||
key: string
|
||||
provider?: string
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)[]
|
||||
}
|
||||
|
||||
export type AuthOuathResult = { url: string; instructions: string } & (
|
||||
| {
|
||||
method: "auto"
|
||||
callback(): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| { key: string }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
method: "code"
|
||||
callback(code: string): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| { key: string }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)
|
||||
|
||||
export interface Hooks {
|
||||
event?: (input: { event: Event }) => Promise<void>
|
||||
config?: (input: Config) => Promise<void>
|
||||
tool?: {
|
||||
[key: string]: ToolDefinition
|
||||
}
|
||||
auth?: {
|
||||
provider: string
|
||||
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
|
||||
methods: (
|
||||
| {
|
||||
type: "oauth"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
>
|
||||
authorize(inputs?: Record<string, string>): Promise<
|
||||
{ url: string; instructions: string } & (
|
||||
| {
|
||||
method: "auto"
|
||||
callback(): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| { key: string }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
| {
|
||||
method: "code"
|
||||
callback(code: string): Promise<
|
||||
| ({
|
||||
type: "success"
|
||||
provider?: string
|
||||
} & (
|
||||
| {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
| { key: string }
|
||||
))
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)
|
||||
>
|
||||
}
|
||||
| {
|
||||
type: "api"
|
||||
label: string
|
||||
prompts?: Array<
|
||||
| {
|
||||
type: "text"
|
||||
key: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
| {
|
||||
type: "select"
|
||||
key: string
|
||||
message: string
|
||||
options: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}>
|
||||
condition?: (inputs: Record<string, string>) => boolean
|
||||
}
|
||||
>
|
||||
authorize?(inputs?: Record<string, string>): Promise<
|
||||
| {
|
||||
type: "success"
|
||||
key: string
|
||||
provider?: string
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
}
|
||||
>
|
||||
}
|
||||
)[]
|
||||
}
|
||||
auth?: AuthHook
|
||||
/**
|
||||
* Called when a new message is received
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -26,4 +26,4 @@
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
ToolListData,
|
||||
ToolListResponses,
|
||||
ToolListErrors,
|
||||
InstanceDisposeData,
|
||||
InstanceDisposeResponses,
|
||||
PathGetData,
|
||||
PathGetResponses,
|
||||
SessionListData,
|
||||
@@ -92,6 +94,16 @@ import type {
|
||||
CommandListResponses,
|
||||
ConfigProvidersData,
|
||||
ConfigProvidersResponses,
|
||||
ProviderListData,
|
||||
ProviderListResponses,
|
||||
ProviderAuthData,
|
||||
ProviderAuthResponses,
|
||||
ProviderOauthAuthorizeData,
|
||||
ProviderOauthAuthorizeResponses,
|
||||
ProviderOauthAuthorizeErrors,
|
||||
ProviderOauthCallbackData,
|
||||
ProviderOauthCallbackResponses,
|
||||
ProviderOauthCallbackErrors,
|
||||
FindTextData,
|
||||
FindTextResponses,
|
||||
FindFilesData,
|
||||
@@ -272,6 +284,18 @@ class Tool extends _HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
class Instance extends _HeyApiClient {
|
||||
/**
|
||||
* Dispose the current instance
|
||||
*/
|
||||
public dispose<ThrowOnError extends boolean = false>(options?: Options<InstanceDisposeData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
|
||||
url: "/instance/dispose",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Path extends _HeyApiClient {
|
||||
/**
|
||||
* Get the current path
|
||||
@@ -554,6 +578,67 @@ class Command extends _HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
class Oauth extends _HeyApiClient {
|
||||
/**
|
||||
* Authorize a provider using OAuth
|
||||
*/
|
||||
public authorize<ThrowOnError extends boolean = false>(options: Options<ProviderOauthAuthorizeData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).post<
|
||||
ProviderOauthAuthorizeResponses,
|
||||
ProviderOauthAuthorizeErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/provider/{id}/oauth/authorize",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback for a provider
|
||||
*/
|
||||
public callback<ThrowOnError extends boolean = false>(options: Options<ProviderOauthCallbackData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).post<
|
||||
ProviderOauthCallbackResponses,
|
||||
ProviderOauthCallbackErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/provider/{id}/oauth/callback",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Provider extends _HeyApiClient {
|
||||
/**
|
||||
* List all providers
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(options?: Options<ProviderListData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).get<ProviderListResponses, unknown, ThrowOnError>({
|
||||
url: "/provider",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider authentication methods
|
||||
*/
|
||||
public auth<ThrowOnError extends boolean = false>(options?: Options<ProviderAuthData, ThrowOnError>) {
|
||||
return (options?.client ?? this._client).get<ProviderAuthResponses, unknown, ThrowOnError>({
|
||||
url: "/provider/auth",
|
||||
...options,
|
||||
})
|
||||
}
|
||||
oauth = new Oauth({ client: this._client })
|
||||
}
|
||||
|
||||
class Find extends _HeyApiClient {
|
||||
/**
|
||||
* Find text in files
|
||||
@@ -891,9 +976,11 @@ export class OpencodeClient extends _HeyApiClient {
|
||||
project = new Project({ client: this._client })
|
||||
config = new Config({ client: this._client })
|
||||
tool = new Tool({ client: this._client })
|
||||
instance = new Instance({ client: this._client })
|
||||
path = new Path({ client: this._client })
|
||||
session = new Session({ client: this._client })
|
||||
command = new Command({ client: this._client })
|
||||
provider = new Provider({ client: this._client })
|
||||
find = new Find({ client: this._client })
|
||||
file = new File({ client: this._client })
|
||||
app = new App({ client: this._client })
|
||||
|
||||
@@ -150,6 +150,7 @@ export type TextPart = {
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
time?: {
|
||||
start: number
|
||||
end?: number
|
||||
@@ -1015,6 +1016,10 @@ export type Config = {
|
||||
* Disable providers that are loaded automatically
|
||||
*/
|
||||
disabled_providers?: Array<string>
|
||||
/**
|
||||
* When set, ONLY these providers will be enabled. All other providers will be ignored
|
||||
*/
|
||||
enabled_providers?: Array<string>
|
||||
/**
|
||||
* Model to use in the format of provider/model, eg anthropic/claude-2
|
||||
*/
|
||||
@@ -1096,6 +1101,8 @@ export type Config = {
|
||||
}
|
||||
}
|
||||
}
|
||||
whitelist?: Array<string>
|
||||
blacklist?: Array<string>
|
||||
options?: {
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
@@ -1233,6 +1240,7 @@ export type TextPartInput = {
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
time?: {
|
||||
start: number
|
||||
end?: number
|
||||
@@ -1331,6 +1339,17 @@ export type Provider = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderAuthMethod = {
|
||||
type: "oauth" | "api"
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ProviderAuthAuthorization = {
|
||||
url: string
|
||||
method: "auto" | "code"
|
||||
instructions: string
|
||||
}
|
||||
|
||||
export type Symbol = {
|
||||
name: string
|
||||
kind: number
|
||||
@@ -1609,6 +1628,24 @@ export type ToolListResponses = {
|
||||
|
||||
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
|
||||
|
||||
export type InstanceDisposeData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/instance/dispose"
|
||||
}
|
||||
|
||||
export type InstanceDisposeResponses = {
|
||||
/**
|
||||
* Instance disposed
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]
|
||||
|
||||
export type PathGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -2482,6 +2519,128 @@ export type ConfigProvidersResponses = {
|
||||
|
||||
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
|
||||
|
||||
export type ProviderListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/provider"
|
||||
}
|
||||
|
||||
export type ProviderListResponses = {
|
||||
/**
|
||||
* List of providers
|
||||
*/
|
||||
200: {
|
||||
all: Array<Provider>
|
||||
default: {
|
||||
[key: string]: string
|
||||
}
|
||||
connected: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses]
|
||||
|
||||
export type ProviderAuthData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/provider/auth"
|
||||
}
|
||||
|
||||
export type ProviderAuthResponses = {
|
||||
/**
|
||||
* Provider auth methods
|
||||
*/
|
||||
200: {
|
||||
[key: string]: Array<ProviderAuthMethod>
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]
|
||||
|
||||
export type ProviderOauthAuthorizeData = {
|
||||
body?: {
|
||||
/**
|
||||
* Auth method index
|
||||
*/
|
||||
method: number
|
||||
}
|
||||
path: {
|
||||
/**
|
||||
* Provider ID
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/provider/{id}/oauth/authorize"
|
||||
}
|
||||
|
||||
export type ProviderOauthAuthorizeErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors]
|
||||
|
||||
export type ProviderOauthAuthorizeResponses = {
|
||||
/**
|
||||
* Authorization URL and method
|
||||
*/
|
||||
200: ProviderAuthAuthorization
|
||||
}
|
||||
|
||||
export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]
|
||||
|
||||
export type ProviderOauthCallbackData = {
|
||||
body?: {
|
||||
/**
|
||||
* Auth method index
|
||||
*/
|
||||
method: number
|
||||
/**
|
||||
* OAuth authorization code
|
||||
*/
|
||||
code?: string
|
||||
}
|
||||
path: {
|
||||
/**
|
||||
* Provider ID
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/provider/{id}/oauth/callback"
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]
|
||||
|
||||
export type ProviderOauthCallbackResponses = {
|
||||
/**
|
||||
* OAuth callback processed successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
|
||||
|
||||
export type FindTextData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -81,8 +81,8 @@ class KeybindsConfig:
|
||||
session_unshare: Union[Unset, str] = "none"
|
||||
session_interrupt: Union[Unset, str] = "esc"
|
||||
session_compact: Union[Unset, str] = "<leader>c"
|
||||
session_child_cycle: Union[Unset, str] = "ctrl+right"
|
||||
session_child_cycle_reverse: Union[Unset, str] = "ctrl+left"
|
||||
session_child_cycle: Union[Unset, str] = "<leader>right"
|
||||
session_child_cycle_reverse: Union[Unset, str] = "<leader>left"
|
||||
messages_page_up: Union[Unset, str] = "pgup"
|
||||
messages_page_down: Union[Unset, str] = "pgdown"
|
||||
messages_half_page_up: Union[Unset, str] = "ctrl+alt+u"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/components/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -89,8 +89,8 @@ A general-purpose agent for researching complex questions, searching for code, a
|
||||
```
|
||||
|
||||
3. **Navigation between sessions**: When subagents create their own child sessions, you can navigate between the parent session and all child sessions using:
|
||||
- **Ctrl+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent
|
||||
- **Ctrl+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent
|
||||
- **\<Leader>+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent
|
||||
- **\<Leader>+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent
|
||||
|
||||
This allows you to seamlessly switch between the main conversation and specialized subagent work.
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
|
||||
"session_unshare": "none",
|
||||
"session_interrupt": "escape",
|
||||
"session_compact": "<leader>c",
|
||||
"session_child_cycle": "ctrl+right",
|
||||
"session_child_cycle_reverse": "ctrl+left",
|
||||
"session_child_cycle": "<leader>+right",
|
||||
"session_child_cycle_reverse": "<leader>+left",
|
||||
"messages_page_up": "pageup",
|
||||
"messages_page_down": "pagedown",
|
||||
"messages_half_page_up": "ctrl+alt+u",
|
||||
|
||||
@@ -35,17 +35,16 @@ Consider using one of the models we recommend.
|
||||
|
||||
However, there are only a few of them that are good at both generating code and tool calling.
|
||||
|
||||
Here are several models, in no particular order, that work well with OpenCode (to name a few):
|
||||
Here are several models that work well with OpenCode, in no particular order. (This is not an exhaustive list):
|
||||
|
||||
- GPT 5
|
||||
- GPT 5 Codex
|
||||
- GPT 5.1
|
||||
- GPT 5.1 Codex
|
||||
- Claude Sonnet 4.5
|
||||
- Claude Sonnet 4
|
||||
- Claude Opus 4.1
|
||||
- Claude Haiku 4.5
|
||||
- Kimi K2
|
||||
- GLM 4.6
|
||||
- Qwen3 Coder
|
||||
- GPT 4.1
|
||||
- Gemini 2.5 Pro
|
||||
- Gemini 3 Pro
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.81",
|
||||
"version": "1.0.85",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user