Ver Fonte

Tui onboarding (#4569)

Co-authored-by: GitHub Action <[email protected]>
Dax há 4 meses atrás
pai
commit
23ea8ba1ce

+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
   "nodes": {
     "nixpkgs": {
     "nixpkgs": {
       "locked": {
       "locked": {
-        "lastModified": 1763464769,
-        "narHash": "sha256-AJHrsT7VoeQzErpBRlLJM1SODcaayp0joAoEA35yiwM=",
+        "lastModified": 1763618868,
+        "narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
         "owner": "NixOS",
         "owner": "NixOS",
         "repo": "nixpkgs",
         "repo": "nixpkgs",
-        "rev": "6f374686605df381de8541c072038472a5ea2e2d",
+        "rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
         "type": "github"
         "type": "github"
       },
       },
       "original": {
       "original": {

+ 22 - 11
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 { Clipboard } from "@tui/util/clipboard"
 import { TextAttributes } from "@opentui/core"
 import { TextAttributes } from "@opentui/core"
 import { RouteProvider, useRoute } from "@tui/context/route"
 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 { Installation } from "@/installation"
 import { Global } from "@/global"
 import { Global } from "@/global"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
 import { DialogProvider, useDialog } from "@tui/ui/dialog"
+import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { SyncProvider, useSync } from "@tui/context/sync"
 import { SyncProvider, useSync } from "@tui/context/sync"
 import { LocalProvider, useLocal } from "@tui/context/local"
 import { LocalProvider, useLocal } from "@tui/context/local"
@@ -293,6 +294,14 @@ function App() {
       },
       },
       category: "System",
       category: "System",
     },
     },
+    {
+      title: "Connect provider",
+      value: "provider.connect",
+      onSelect: () => {
+        dialog.replace(() => <DialogProviderList />)
+      },
+      category: "System",
+    },
     {
     {
       title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
       title: `Switch to ${mode() === "dark" ? "light" : "dark"} mode`,
       value: "theme.switch_mode",
       value: "theme.switch_mode",
@@ -451,16 +460,18 @@ function App() {
             <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
             <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
           </box>
           </box>
         </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>
     </box>
     </box>
   )
   )

+ 15 - 10
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 = {
 export const SplitBorder = {
   border: ["left" as const, "right" as const],
   border: ["left" as const, "right" as const],
   customBorderChars: {
   customBorderChars: {
-    topLeft: "",
-    bottomLeft: "",
+    ...EmptyBorder,
     vertical: "┃",
     vertical: "┃",
-    topRight: "",
-    bottomRight: "",
-    horizontal: "",
-    bottomT: "",
-    topT: "",
-    cross: "",
-    leftT: "",
-    rightT: "",
   },
   },
 }
 }

+ 66 - 11
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 { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
 import { useDialog } from "@tui/ui/dialog"
 import { useDialog } from "@tui/ui/dialog"
 import { useTheme } from "../context/theme"
 import { useTheme } from "../context/theme"
+import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
 
 
 function Free() {
 function Free() {
   const { theme } = useTheme()
   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() {
 export function DialogModel() {
@@ -17,9 +27,16 @@ export function DialogModel() {
   const dialog = useDialog()
   const dialog = useDialog()
   const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
   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(() => {
   const options = createMemo(() => {
     return [
     return [
-      ...(!ref()?.filter
+      ...(showRecent()
         ? local.model.recent().flatMap((item) => {
         ? local.model.recent().flatMap((item) => {
             const provider = sync.data.provider.find((x) => x.id === item.providerID)!
             const provider = sync.data.provider.find((x) => x.id === item.providerID)!
             if (!provider) return []
             if (!provider) return []
@@ -35,7 +52,17 @@ export function DialogModel() {
                 title: model.name ?? item.modelID,
                 title: model.name ?? item.modelID,
                 description: provider.name,
                 description: provider.name,
                 category: "Recent",
                 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,
                 modelID: model,
               },
               },
               title: info.name ?? 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),
             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 (
   return (
     <DialogSelect
     <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}
       ref={setRef}
       title="Select model"
       title="Select model"
       current={local.model.current()}
       current={local.model.current()}
       options={options()}
       options={options()}
-      onSelect={(option) => {
-        dialog.clear()
-        local.model.set(option.value, { recent: true })
-      }}
     />
     />
   )
   )
 }
 }

+ 223 - 0
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 />)
+      }}
+    />
+  )
+}

+ 141 - 50
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 { useLocal } from "@tui/context/local"
 import { useTheme } from "@tui/context/theme"
 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 { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
 import { useSync } from "@tui/context/sync"
@@ -29,6 +19,8 @@ import { Clipboard } from "../../util/clipboard"
 import type { FilePart } from "@opencode-ai/sdk"
 import type { FilePart } from "@opencode-ai/sdk"
 import { TuiEvent } from "../../event"
 import { TuiEvent } from "../../event"
 import { iife } from "@/util/iife"
 import { iife } from "@/util/iife"
+import { Locale } from "@/util/locale"
+import { Shimmer } from "../../ui/shimmer"
 
 
 export type PromptProps = {
 export type PromptProps = {
   sessionID?: string
   sessionID?: string
@@ -57,7 +49,7 @@ export function Prompt(props: PromptProps) {
   const sdk = useSDK()
   const sdk = useSDK()
   const route = useRoute()
   const route = useRoute()
   const sync = useSync()
   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 history = usePromptHistory()
   const command = useCommandDialog()
   const command = useCommandDialog()
   const renderer = useRenderer()
   const renderer = useRenderer()
@@ -222,12 +214,17 @@ export function Prompt(props: PromptProps) {
         title: "Interrupt session",
         title: "Interrupt session",
         value: "session.interrupt",
         value: "session.interrupt",
         keybind: "session_interrupt",
         keybind: "session_interrupt",
-        disabled: status() !== "working",
+        disabled: status().type === "idle",
         category: "Session",
         category: "Session",
         onSelect: (dialog) => {
         onSelect: (dialog) => {
-          if (!props.sessionID) return
           if (autocomplete.visible) return
           if (autocomplete.visible) return
           if (!input.focused) 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)
           setStore("interrupt", store.interrupt + 1)
 
 
@@ -542,6 +539,16 @@ export function Prompt(props: PromptProps) {
     return
     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 (
   return (
     <>
     <>
       <Autocomplete
       <Autocomplete
@@ -566,17 +573,22 @@ export function Prompt(props: PromptProps) {
       />
       />
       <box ref={(r) => (anchor = r)}>
       <box ref={(r) => (anchor = r)}>
         <box
         <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
             <textarea
               placeholder={
               placeholder={
                 props.showPlaceholder
                 props.showPlaceholder
@@ -751,35 +763,114 @@ export function Prompt(props: PromptProps) {
               cursorColor={theme.primary}
               cursorColor={theme.primary}
               syntaxStyle={syntax()}
               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>
-          <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>
         <box flexDirection="row" justifyContent="space-between">
         <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>
               </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>
               </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>
       </box>
       </box>
     </>
     </>

+ 2 - 1
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -10,6 +10,7 @@ import { createSimpleContext } from "./helper"
 import { useToast } from "../ui/toast"
 import { useToast } from "../ui/toast"
 import { Provider } from "@/provider/provider"
 import { Provider } from "@/provider/provider"
 import { useArgs } from "./args"
 import { useArgs } from "./args"
+import { RGBA } from "@opentui/core"
 
 
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   name: "Local",
@@ -91,7 +92,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         },
         },
         color(name: string) {
         color(name: string) {
           const agent = agents().find((x) => x.name === name)
           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)
           const index = agents().findIndex((x) => x.name === name)
           return colors()[index % colors().length]
           return colors()[index % colors().length]
         },
         },

+ 0 - 1
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) => {
     sdk.event.subscribe().then(async (events) => {
       for await (const event of events.stream) {
       for await (const event of events.stream) {
-        console.log("event", event.type)
         emitter.emit(event.type, event)
         emitter.emit(event.type, event)
       }
       }
     })
     })

+ 24 - 3
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -12,6 +12,8 @@ import type {
   McpStatus,
   McpStatus,
   FormatterStatus,
   FormatterStatus,
   SessionStatus,
   SessionStatus,
+  ProviderListResponse,
+  ProviderAuthMethod,
 } from "@opencode-ai/sdk"
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
 import { useSDK } from "@tui/context/sdk"
@@ -28,6 +30,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       status: "loading" | "partial" | "complete"
       status: "loading" | "partial" | "complete"
       provider: Provider[]
       provider: Provider[]
       provider_default: Record<string, string>
       provider_default: Record<string, string>
+      provider_next: ProviderListResponse
+      provider_auth: Record<string, ProviderAuthMethod[]>
       agent: Agent[]
       agent: Agent[]
       command: Command[]
       command: Command[]
       permission: {
       permission: {
@@ -56,6 +60,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       }
       }
       formatter: FormatterStatus[]
       formatter: FormatterStatus[]
     }>({
     }>({
+      provider_next: {
+        all: [],
+        default: {},
+        connected: [],
+      },
+      provider_auth: {},
       config: {},
       config: {},
       status: "loading",
       status: "loading",
       agent: [],
       agent: [],
@@ -232,20 +242,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
 
     const exit = useExit()
     const exit = useExit()
 
 
-    onMount(() => {
+    async function bootstrap() {
       // blocking
       // blocking
-      Promise.all([
+      await Promise.all([
         sdk.client.config.providers({ throwOnError: true }).then((x) => {
         sdk.client.config.providers({ throwOnError: true }).then((x) => {
           batch(() => {
           batch(() => {
             setStore("provider", x.data!.providers)
             setStore("provider", x.data!.providers)
             setStore("provider_default", x.data!.default)
             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.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
         sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
         sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
       ])
       ])
         .then(() => {
         .then(() => {
-          setStore("status", "partial")
+          if (store.status !== "complete") setStore("status", "partial")
           // non-blocking
           // non-blocking
           Promise.all([
           Promise.all([
             sdk.client.session.list().then((x) =>
             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.mcp.status().then((x) => setStore("mcp", x.data!)),
             sdk.client.formatter.status().then((x) => setStore("formatter", 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.session.status().then((x) => setStore("session_status", x.data!)),
+            sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
           ]).then(() => {
           ]).then(() => {
             setStore("status", "complete")
             setStore("status", "complete")
           })
           })
@@ -266,6 +282,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         .catch(async (e) => {
         .catch(async (e) => {
           await exit(e)
           await exit(e)
         })
         })
+    }
+
+    onMount(() => {
+      bootstrap()
     })
     })
 
 
     const result = {
     const result = {
@@ -320,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           console.log("synced in " + (Date.now() - now), sessionID)
           console.log("synced in " + (Date.now() - now), sessionID)
         },
         },
       },
       },
+      bootstrap,
     }
     }
     return result
     return result
   },
   },

+ 49 - 48
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -601,11 +601,9 @@ export function Session() {
           }
           }
 
 
           // Prompt for optional filename
           // 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
           // Cancel if user pressed escape
           if (customFilename === null) return
           if (customFilename === null) return
@@ -904,52 +902,55 @@ function UserMessage(props: {
       <Show when={text()}>
       <Show when={text()}>
         <box
         <box
           id={props.message.id}
           id={props.message.id}
-          onMouseOver={() => {
-            setHover(true)
-          }}
-          onMouseOut={() => {
-            setHover(false)
-          }}
-          onMouseUp={props.onMouseUp}
           border={["left"]}
           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()}
           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>
             </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>
         </box>
       </Show>
       </Show>
       <Show when={compaction()}>
       <Show when={compaction()}>
@@ -1007,7 +1008,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
         </box>
         </box>
       </Show>
       </Show>
       <Switch>
       <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}>
           <box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
             <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
             <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
             <Shimmer text={props.message.modelID} color={theme.text} />
             <Shimmer text={props.message.modelID} color={theme.text} />

+ 17 - 15
packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx

@@ -1,11 +1,13 @@
 import { TextareaRenderable, TextAttributes } from "@opentui/core"
 import { TextareaRenderable, TextAttributes } from "@opentui/core"
 import { useTheme } from "../context/theme"
 import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
 import { useDialog, type DialogContext } from "./dialog"
-import { onMount } from "solid-js"
+import { onMount, type JSX } from "solid-js"
 import { useKeyboard } from "@opentui/solid"
 import { useKeyboard } from "@opentui/solid"
 
 
 export type DialogPromptProps = {
 export type DialogPromptProps = {
   title: string
   title: string
+  description?: () => JSX.Element
+  placeholder?: string
   value?: string
   value?: string
   onConfirm?: (value: string) => void
   onConfirm?: (value: string) => void
   onCancel?: () => void
   onCancel?: () => void
@@ -19,12 +21,11 @@ export function DialogPrompt(props: DialogPromptProps) {
   useKeyboard((evt) => {
   useKeyboard((evt) => {
     if (evt.name === "return") {
     if (evt.name === "return") {
       props.onConfirm?.(textarea.plainText)
       props.onConfirm?.(textarea.plainText)
-      dialog.clear()
     }
     }
   })
   })
 
 
   onMount(() => {
   onMount(() => {
-    dialog.setSize("large")
+    dialog.setSize("medium")
     setTimeout(() => {
     setTimeout(() => {
       textarea.focus()
       textarea.focus()
     }, 1)
     }, 1)
@@ -37,35 +38,36 @@ export function DialogPrompt(props: DialogPromptProps) {
         <text attributes={TextAttributes.BOLD}>{props.title}</text>
         <text attributes={TextAttributes.BOLD}>{props.title}</text>
         <text fg={theme.textMuted}>esc</text>
         <text fg={theme.textMuted}>esc</text>
       </box>
       </box>
-      <box>
+      <box gap={1}>
+        {props.description}
         <textarea
         <textarea
           onSubmit={() => {
           onSubmit={() => {
             props.onConfirm?.(textarea.plainText)
             props.onConfirm?.(textarea.plainText)
-            dialog.clear()
           }}
           }}
+          height={3}
           keyBindings={[{ name: "return", action: "submit" }]}
           keyBindings={[{ name: "return", action: "submit" }]}
           ref={(val: TextareaRenderable) => (textarea = val)}
           ref={(val: TextareaRenderable) => (textarea = val)}
           initialValue={props.value}
           initialValue={props.value}
-          placeholder="Enter text"
+          placeholder={props.placeholder ?? "Enter text"}
         />
         />
       </box>
       </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>
     </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) => {
   return new Promise<string | null>((resolve) => {
     dialog.replace(
     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),
       () => resolve(null),
     )
     )

+ 15 - 11
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -157,7 +157,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
 
   return (
   return (
     <box gap={1}>
     <box gap={1}>
-      <box paddingLeft={3} paddingRight={2}>
+      <box paddingLeft={4} paddingRight={4}>
         <box flexDirection="row" justifyContent="space-between">
         <box flexDirection="row" justifyContent="space-between">
           <text fg={theme.text} attributes={TextAttributes.BOLD}>
           <text fg={theme.text} attributes={TextAttributes.BOLD}>
             {props.title}
             {props.title}
@@ -184,8 +184,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         </box>
         </box>
       </box>
       </box>
       <scrollbox
       <scrollbox
-        paddingLeft={2}
-        paddingRight={2}
+        paddingLeft={1}
+        paddingRight={1}
         scrollbarOptions={{ visible: false }}
         scrollbarOptions={{ visible: false }}
         ref={(r: ScrollBoxRenderable) => (scroll = r)}
         ref={(r: ScrollBoxRenderable) => (scroll = r)}
         maxHeight={height()}
         maxHeight={height()}
@@ -194,7 +194,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           {([category, options], index) => (
           {([category, options], index) => (
             <>
             <>
               <Show when={category}>
               <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}>
                   <text fg={theme.accent} attributes={TextAttributes.BOLD}>
                     {category}
                     {category}
                   </text>
                   </text>
@@ -203,6 +203,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
               <For each={options}>
               <For each={options}>
                 {(option) => {
                 {(option) => {
                   const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
                   const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
+                  const current = createMemo(() => isDeepEqual(option.value, props.current))
                   return (
                   return (
                     <box
                     <box
                       id={JSON.stringify(option.value)}
                       id={JSON.stringify(option.value)}
@@ -217,8 +218,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                         moveTo(index)
                         moveTo(index)
                       }}
                       }}
                       backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
                       backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
-                      paddingLeft={1}
-                      paddingRight={1}
+                      paddingLeft={current() ? 1 : 3}
+                      paddingRight={3}
                       gap={1}
                       gap={1}
                     >
                     >
                       <Option
                       <Option
@@ -226,7 +227,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                         footer={option.footer}
                         footer={option.footer}
                         description={option.description !== category ? option.description : undefined}
                         description={option.description !== category ? option.description : undefined}
                         active={active()}
                         active={active()}
-                        current={isDeepEqual(option.value, props.current)}
+                        current={current()}
                       />
                       />
                     </box>
                     </box>
                   )
                   )
@@ -236,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           )}
           )}
         </For>
         </For>
       </scrollbox>
       </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 ?? []}>
         <For each={props.keybind ?? []}>
           {(item) => (
           {(item) => (
             <text>
             <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>
             </text>
           )}
           )}
         </For>
         </For>
@@ -268,7 +271,7 @@ function Option(props: {
           fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
           fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
           marginRight={0.5}
           marginRight={0.5}
         >
         >
-          
+          
         </text>
         </text>
       </Show>
       </Show>
       <text
       <text
@@ -277,6 +280,7 @@ function Option(props: {
         attributes={props.active ? TextAttributes.BOLD : undefined}
         attributes={props.active ? TextAttributes.BOLD : undefined}
         overflow="hidden"
         overflow="hidden"
         wrapMode="none"
         wrapMode="none"
+        paddingLeft={3}
       >
       >
         {Locale.truncate(props.title, 62)}
         {Locale.truncate(props.title, 62)}
         <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
         <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>

+ 24 - 1
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 { useTheme } from "@tui/context/theme"
 import { Renderable, RGBA } from "@opentui/core"
 import { Renderable, RGBA } from "@opentui/core"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
+import { Clipboard } from "@tui/util/clipboard"
+import { useToast } from "./toast"
 
 
 export function Dialog(
 export function Dialog(
   props: ParentProps<{
   props: ParentProps<{
@@ -12,10 +14,12 @@ export function Dialog(
 ) {
 ) {
   const dimensions = useTerminalDimensions()
   const dimensions = useTerminalDimensions()
   const { theme } = useTheme()
   const { theme } = useTheme()
+  const renderer = useRenderer()
 
 
   return (
   return (
     <box
     <box
       onMouseUp={async () => {
       onMouseUp={async () => {
+        if (renderer.getSelection()) return
         props.onClose?.()
         props.onClose?.()
       }}
       }}
       width={dimensions().width}
       width={dimensions().width}
@@ -29,6 +33,7 @@ export function Dialog(
     >
     >
       <box
       <box
         onMouseUp={async (e) => {
         onMouseUp={async (e) => {
+          if (renderer.getSelection()) return
           e.stopPropagation()
           e.stopPropagation()
         }}
         }}
         width={props.size === "large" ? 80 : 60}
         width={props.size === "large" ? 80 : 60}
@@ -124,10 +129,28 @@ const ctx = createContext<DialogContext>()
 
 
 export function DialogProvider(props: ParentProps) {
 export function DialogProvider(props: ParentProps) {
   const value = init()
   const value = init()
+  const renderer = useRenderer()
+  const toast = useToast()
   return (
   return (
     <ctx.Provider value={value}>
     <ctx.Provider value={value}>
       {props.children}
       {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}>
         <Show when={value.stack.length}>
           <Dialog onClose={() => value.clear()} size={value.size}>
           <Dialog onClose={() => value.clear()} size={value.size}>
             {value.stack.at(-1)!.element}
             {value.stack.at(-1)!.element}

+ 1 - 0
packages/opencode/src/project/instance.ts

@@ -51,6 +51,7 @@ export const Instance = {
   async dispose() {
   async dispose() {
     Log.Default.info("disposing instance", { directory: Instance.directory })
     Log.Default.info("disposing instance", { directory: Instance.directory })
     await State.dispose(Instance.directory)
     await State.dispose(Instance.directory)
+    cache.delete(Instance.directory)
   },
   },
   async disposeAll() {
   async disposeAll() {
     Log.Default.info("disposing all instances")
     Log.Default.info("disposing all instances")

+ 1 - 1
packages/opencode/src/project/state.ts

@@ -57,7 +57,7 @@ export namespace State {
 
 
       tasks.push(task)
       tasks.push(task)
     }
     }
-    entries.delete(key)
+    entries.clear()
     await Promise.all(tasks)
     await Promise.all(tasks)
     disposalFinished = true
     disposalFinished = true
     log.info("state disposal completed", { key })
     log.info("state disposal completed", { key })

+ 143 - 0
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({}))
+}

+ 0 - 2
packages/opencode/src/provider/provider.ts

@@ -1,5 +1,4 @@
 import z from "zod"
 import z from "zod"
-import path from "path"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
 import { mergeDeep, sortBy } from "remeda"
 import { mergeDeep, sortBy } from "remeda"
 import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
 import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,7 +9,6 @@ import { ModelsDev } from "./models"
 import { NamedError } from "../util/error"
 import { NamedError } from "../util/error"
 import { Auth } from "../auth"
 import { Auth } from "../auth"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
-import { Global } from "../global"
 import { Flag } from "../flag/flag"
 import { Flag } from "../flag/flag"
 import { iife } from "@/util/iife"
 import { iife } from "@/util/iife"
 
 

+ 1 - 1
packages/opencode/src/provider/transform.ts

@@ -147,7 +147,7 @@ export namespace ProviderTransform {
       result["promptCacheKey"] = sessionID
       result["promptCacheKey"] = sessionID
     }
     }
 
 
-    if (providerID === "google") {
+    if (providerID === "google" || (providerID === "opencode" && modelID.includes("gemini-3"))) {
       result["thinkingConfig"] = {
       result["thinkingConfig"] = {
         includeThoughts: true,
         includeThoughts: true,
       }
       }

+ 154 - 0
packages/opencode/src/server/server.ts

@@ -23,6 +23,7 @@ import { Instance } from "../project/instance"
 import { Agent } from "../agent/agent"
 import { Agent } from "../agent/agent"
 import { Auth } from "../auth"
 import { Auth } from "../auth"
 import { Command } from "../command"
 import { Command } from "../command"
+import { ProviderAuth } from "../provider/auth"
 import { Global } from "../global"
 import { Global } from "../global"
 import { ProjectRoute } from "./project"
 import { ProjectRoute } from "./project"
 import { ToolRegistry } from "../tool/registry"
 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(
       .get(
         "/path",
         "/path",
         describeRoute({
         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(
       .get(
         "/find",
         "/find",
         describeRoute({
         describeRoute({

+ 4 - 0
packages/opencode/src/session/prompt.ts

@@ -49,6 +49,9 @@ import { SessionProcessor } from "./processor"
 import { TaskTool } from "@/tool/task"
 import { TaskTool } from "@/tool/task"
 import { SessionStatus } from "./status"
 import { SessionStatus } from "./status"
 
 
+// @ts-ignore
+globalThis.AI_SDK_LOG_WARNINGS = false
+
 export namespace SessionPrompt {
 export namespace SessionPrompt {
   const log = Log.create({ service: "session.prompt" })
   const log = Log.create({ service: "session.prompt" })
   export const OUTPUT_TOKEN_MAX = 32_000
   export const OUTPUT_TOKEN_MAX = 32_000
@@ -239,6 +242,7 @@ export namespace SessionPrompt {
 
 
     let step = 0
     let step = 0
     while (true) {
     while (true) {
+      SessionStatus.set(sessionID, { type: "busy" })
       log.info("loop", { step, sessionID })
       log.info("loop", { step, sessionID })
       if (abort.aborted) break
       if (abort.aborted) break
       let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
       let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))

+ 110 - 108
packages/plugin/src/index.ts

@@ -26,120 +26,122 @@ export type PluginInput = {
 
 
 export type Plugin = (input: PluginInput) => Promise<Hooks>
 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 {
 export interface Hooks {
   event?: (input: { event: Event }) => Promise<void>
   event?: (input: { event: Event }) => Promise<void>
   config?: (input: Config) => Promise<void>
   config?: (input: Config) => Promise<void>
   tool?: {
   tool?: {
     [key: string]: ToolDefinition
     [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
    * Called when a new message is received
    */
    */

+ 87 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -19,6 +19,8 @@ import type {
   ToolListData,
   ToolListData,
   ToolListResponses,
   ToolListResponses,
   ToolListErrors,
   ToolListErrors,
+  InstanceDisposeData,
+  InstanceDisposeResponses,
   PathGetData,
   PathGetData,
   PathGetResponses,
   PathGetResponses,
   SessionListData,
   SessionListData,
@@ -92,6 +94,16 @@ import type {
   CommandListResponses,
   CommandListResponses,
   ConfigProvidersData,
   ConfigProvidersData,
   ConfigProvidersResponses,
   ConfigProvidersResponses,
+  ProviderListData,
+  ProviderListResponses,
+  ProviderAuthData,
+  ProviderAuthResponses,
+  ProviderOauthAuthorizeData,
+  ProviderOauthAuthorizeResponses,
+  ProviderOauthAuthorizeErrors,
+  ProviderOauthCallbackData,
+  ProviderOauthCallbackResponses,
+  ProviderOauthCallbackErrors,
   FindTextData,
   FindTextData,
   FindTextResponses,
   FindTextResponses,
   FindFilesData,
   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 {
 class Path extends _HeyApiClient {
   /**
   /**
    * Get the current path
    * 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 {
 class Find extends _HeyApiClient {
   /**
   /**
    * Find text in files
    * Find text in files
@@ -891,9 +976,11 @@ export class OpencodeClient extends _HeyApiClient {
   project = new Project({ client: this._client })
   project = new Project({ client: this._client })
   config = new Config({ client: this._client })
   config = new Config({ client: this._client })
   tool = new Tool({ client: this._client })
   tool = new Tool({ client: this._client })
+  instance = new Instance({ client: this._client })
   path = new Path({ client: this._client })
   path = new Path({ client: this._client })
   session = new Session({ client: this._client })
   session = new Session({ client: this._client })
   command = new Command({ client: this._client })
   command = new Command({ client: this._client })
+  provider = new Provider({ client: this._client })
   find = new Find({ client: this._client })
   find = new Find({ client: this._client })
   file = new File({ client: this._client })
   file = new File({ client: this._client })
   app = new App({ client: this._client })
   app = new App({ client: this._client })

+ 151 - 0
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 = {
 export type Symbol = {
   name: string
   name: string
   kind: number
   kind: number
@@ -1611,6 +1622,24 @@ export type ToolListResponses = {
 
 
 export type ToolListResponse = ToolListResponses[keyof 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 = {
 export type PathGetData = {
   body?: never
   body?: never
   path?: never
   path?: never
@@ -2484,6 +2513,128 @@ export type ConfigProvidersResponses = {
 
 
 export type ConfigProvidersResponse = ConfigProvidersResponses[keyof 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 = {
 export type FindTextData = {
   body?: never
   body?: never
   path?: never
   path?: never