summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2025-11-21 00:21:06 -0500
committerGitHub <[email protected]>2025-11-21 00:21:06 -0500
commit23ea8ba1ceb35358c62ba1051ba402223d0fb5b3 (patch)
tree675096b0f5d54e5ff766b20191967a8d3f491a86
parentc417fec2464079a6e0bba3450d5e41c5394282bc (diff)
downloadopencode-23ea8ba1ceb35358c62ba1051ba402223d0fb5b3.tar.gz
opencode-23ea8ba1ceb35358c62ba1051ba402223d0fb5b3.zip
Tui onboarding (#4569)
Co-authored-by: GitHub Action <[email protected]>
-rw-r--r--flake.lock6
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx33
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/border.tsx25
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx77
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx223
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx191
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/local.tsx3
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sdk.tsx1
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx27
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx97
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx32
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx26
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog.tsx25
-rw-r--r--packages/opencode/src/project/instance.ts1
-rw-r--r--packages/opencode/src/project/state.ts2
-rw-r--r--packages/opencode/src/provider/auth.ts143
-rw-r--r--packages/opencode/src/provider/provider.ts2
-rw-r--r--packages/opencode/src/provider/transform.ts2
-rw-r--r--packages/opencode/src/server/server.ts154
-rw-r--r--packages/opencode/src/session/prompt.ts4
-rw-r--r--packages/plugin/src/index.ts218
-rw-r--r--packages/sdk/js/src/gen/sdk.gen.ts87
-rw-r--r--packages/sdk/js/src/gen/types.gen.ts151
23 files changed, 1253 insertions, 277 deletions
diff --git a/flake.lock b/flake.lock
index cdab71ce7..1150e2751 100644
--- a/flake.lock
+++ b/flake.lock
@@ -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": {
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4f990e76e..7bb10de89 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -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"
@@ -294,6 +295,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",
onSelect: () => {
@@ -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>
)
diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx
index 9cbb96068..333071020 100644
--- a/packages/opencode/src/cli/cmd/tui/component/border.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/border.tsx
@@ -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: "",
},
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
index bcd1d98d5..35e885243 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
@@ -5,10 +5,20 @@ import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useTheme } from "../context/theme"
+import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
function Free() {
const { theme } = useTheme()
- return <span style={{ fg: theme.secondary }}>Free</span>
+ return <span style={{ fg: theme.text }}>Free</span>
+}
+const PROVIDER_PRIORITY: Record<string, number> = {
+ opencode: 0,
+ anthropic: 1,
+ "github-copilot": 2,
+ openai: 3,
+ google: 4,
+ openrouter: 5,
+ vercel: 6,
}
export function DialogModel() {
@@ -17,9 +27,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 +52,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 +83,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,
+ 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",
+ }
+ }),
+ filter((x) => PROVIDER_PRIORITY[x.value] !== undefined),
+ sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
+ )
+ : []),
]
})
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 })
- }}
/>
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
new file mode 100644
index 000000000..0211d029f
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
@@ -0,0 +1,223 @@
+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,
+ vercel: 6,
+}
+
+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 />)
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 8281ab617..17ac2381c 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -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,35 +763,114 @@ 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}>
+ <Shimmer text="Working" color={theme.text} />
+ <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>
</>
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index c2db85442..f9963fae8 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -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]
},
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 8b7564eb5..41f69f0d9 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -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)
}
})
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 74fea2fd0..a5e13adb4 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -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
},
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f0954ed52..f028e8a6c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -601,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
@@ -904,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()}>
@@ -1007,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} />
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
index eaf427aff..83f8e27fc 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
@@ -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),
)
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index 285c039c1..7beef9b08 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -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>
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
index 14a9f7152..9b773111c 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -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}
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 6e8ebb7a0..4defefa51 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -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")
diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts
index 5846bf856..c1ac23c5d 100644
--- a/packages/opencode/src/project/state.ts
+++ b/packages/opencode/src/project/state.ts
@@ -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 })
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
new file mode 100644
index 000000000..fb0016039
--- /dev/null
+++ b/packages/opencode/src/provider/auth.ts
@@ -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({}))
+}
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 6a1754429..228c855aa 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1,5 +1,4 @@
import z from "zod"
-import path from "path"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,7 +9,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"
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 32968cfeb..59a41c086 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -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,
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index ecdffa0df..1ab4185c2 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -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({
@@ -1164,6 +1186,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({
description: "Find text in files",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 443d24046..ff060ac21 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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))
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 0601e5877..ab7aac251 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -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
*/
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index 04dc29ccd..dc2247990 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -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 })
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index c776c8dc9..ebd1140f1 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1333,6 +1333,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
@@ -1611,6 +1622,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
@@ -2484,6 +2513,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