mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-21 16:17:35 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee0a316593 | ||
|
|
5688dd17c5 |
@@ -5,6 +5,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "./editor/dom"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionOwnership } from "@/session/session-ownership"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
|
||||
const withCategory = (category: string) => {
|
||||
return (option: Omit<CommandOption, "category">): CommandOption => ({
|
||||
@@ -23,6 +24,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
const model = input.model ?? local.model
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const providerCommand = withCategory(language.t("command.category.provider"))
|
||||
|
||||
// Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run.
|
||||
const connectProvider = async () => {
|
||||
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
|
||||
void dialog.show(() => <DialogConnectProvider directory={decode64(local.slug())} />)
|
||||
}
|
||||
|
||||
const chooseModel = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
@@ -62,6 +70,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => model.variant.cycle(),
|
||||
}),
|
||||
providerCommand({
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
description: language.t("command.provider.connect.description"),
|
||||
slash: "connect",
|
||||
onSelect: connectProvider,
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
|
||||
@@ -9,10 +9,29 @@ import { createStore, produce } from "solid-js/store"
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
|
||||
// Console sign-in is the connection method for both providers.
|
||||
export const CONSOLE_INTEGRATION = "opencode"
|
||||
export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
|
||||
export function consoleIntegration(provider: string) {
|
||||
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
|
||||
}
|
||||
|
||||
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
|
||||
return Object.fromEntries(
|
||||
(method?.form ?? []).flatMap((field) =>
|
||||
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
) as FormAnswer
|
||||
}
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
/** Picks the method to start without asking when the integration exposes several. */
|
||||
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -43,15 +62,24 @@ export function createProviderConnectionController(options: {
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
auto: false,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
disposed: false,
|
||||
// An attempt the server still considers open; cancelled when the dialog goes away.
|
||||
attempt: undefined as Authorization | undefined,
|
||||
}
|
||||
const currentMethod = createMemo(() =>
|
||||
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
|
||||
)
|
||||
const autoIndex = createMemo(() => {
|
||||
if (integration.loading) return undefined
|
||||
const values = methods()
|
||||
if (values.length === 1) return 0
|
||||
return options.autoSelect?.(values)
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
@@ -109,6 +137,14 @@ export function createProviderConnectionController(options: {
|
||||
)
|
||||
}
|
||||
|
||||
const cancelAttempt = () => {
|
||||
const attempt = polling.attempt
|
||||
polling.attempt = undefined
|
||||
if (!attempt) return
|
||||
void serverSDK.api.integration.oauth
|
||||
.cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const cancelPolling = () => {
|
||||
polling.generation++
|
||||
if (polling.timer === undefined) return
|
||||
@@ -117,6 +153,7 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
polling.attempt = undefined
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
@@ -140,6 +177,7 @@ export function createProviderConnectionController(options: {
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
polling.attempt = undefined
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
@@ -151,26 +189,35 @@ export function createProviderConnectionController(options: {
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
polling.attempt = undefined
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
polling.attempt = undefined
|
||||
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
}
|
||||
const open = () => {
|
||||
const url = store.authorization?.url
|
||||
if (url) platform.openExternal(url)
|
||||
}
|
||||
const select = async (index: number, answer?: FormAnswer) => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.form?.length && !answer) {
|
||||
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
|
||||
if (visible && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
const merged = { ...hiddenDefaults(selected), ...answer }
|
||||
if (selected.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
|
||||
return
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
@@ -183,11 +230,11 @@ export function createProviderConnectionController(options: {
|
||||
.connect({
|
||||
integrationID: options.provider(),
|
||||
methodID: selected.id,
|
||||
...(answer ? { answer } : {}),
|
||||
...(Object.keys(merged).length ? { answer: merged } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => {
|
||||
if (options.provider() === "opencode" && platform.platform === "desktop") {
|
||||
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
|
||||
const url = new URL(response.data.url)
|
||||
url.searchParams.set("client_id", "opencode-desktop")
|
||||
response.data.url = url.href
|
||||
@@ -195,16 +242,39 @@ export function createProviderConnectionController(options: {
|
||||
return { ok: true as const, authorization: response.data }
|
||||
})
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: String(result.error) })
|
||||
if (polling.disposed || generation !== polling.generation) {
|
||||
if (result.ok)
|
||||
void serverSDK.api.integration.oauth
|
||||
.cancel({
|
||||
integrationID: options.provider(),
|
||||
attemptID: result.authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
polling.attempt = result.authorization
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
// Same as `opencode auth login`: hand the user straight to the browser instead of
|
||||
// asking them to click a link and retype a code.
|
||||
platform.openExternal(result.authorization.url)
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const retry = () => {
|
||||
const index = store.methodIndex
|
||||
if (index === undefined) return
|
||||
void select(index, store.formAnswer)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
@@ -236,15 +306,16 @@ export function createProviderConnectionController(options: {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || integration.loading || methods().length !== 1) return
|
||||
auto = true
|
||||
void select(0)
|
||||
const index = autoIndex()
|
||||
if (store.auto || index === undefined) return
|
||||
setStore("auto", true)
|
||||
void select(index)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -254,11 +325,19 @@ export function createProviderConnectionController(options: {
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
// True while nothing useful can be shown yet: the integration is loading, a method is
|
||||
// about to be picked automatically, or the authorization request is in flight.
|
||||
busy: () =>
|
||||
integration.loading ||
|
||||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
|
||||
store.state === "pending",
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
select,
|
||||
reset,
|
||||
retry,
|
||||
open,
|
||||
connectKey,
|
||||
completeCode,
|
||||
},
|
||||
|
||||
@@ -8,16 +8,23 @@ import { TextField } from "@opencode/ui/text-field"
|
||||
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { CustomProviderForm } from "@/providers/credentials/dialog"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
|
||||
import {
|
||||
CONSOLE_PROVIDERS,
|
||||
consoleIntegration,
|
||||
createProviderConnectionController,
|
||||
type ProviderConnectMethod,
|
||||
} from "./controller"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
|
||||
@@ -258,13 +265,39 @@ function ProviderConnection(props: {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const global = useGlobal()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const directory = () => props.directory ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
const integrationID = () => consoleIntegration(props.provider)
|
||||
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
|
||||
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => props.provider,
|
||||
provider: integrationID,
|
||||
directory,
|
||||
autoSelect: (methods) => {
|
||||
if (!isConsole()) return undefined
|
||||
const index = methods.findIndex((method) => method.type === "oauth")
|
||||
return index === -1 ? undefined : index
|
||||
},
|
||||
onComplete: () => {
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
|
||||
const linked = (data.location.provider.list(location()) ?? []).filter(
|
||||
(item) => item.id === props.provider || item.integrationID === integrationID(),
|
||||
)
|
||||
const ids = new Set(linked.map((item) => item.id))
|
||||
global.models.show(
|
||||
(data.location.model.list(location()) ?? [])
|
||||
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
|
||||
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -276,7 +309,11 @@ function ProviderConnection(props: {
|
||||
})
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||
name:
|
||||
integrations.list().find((item) => item.id === props.provider)?.name ??
|
||||
providers.all().get(props.provider)?.name ??
|
||||
controller.integration()?.name ??
|
||||
props.provider,
|
||||
}))
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
@@ -298,6 +335,13 @@ function ProviderConnection(props: {
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
const code = createMemo(() => {
|
||||
const instructions = controller.authorization()?.instructions
|
||||
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
|
||||
return instructions
|
||||
})
|
||||
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
|
||||
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
|
||||
|
||||
function AuthFormView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
@@ -307,7 +351,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const fields = createMemo<StringForm[]>(() => {
|
||||
const value = controller.currentMethod()
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
|
||||
})
|
||||
const matches = (field: StringForm, value: Record<string, string>) => {
|
||||
return (field.when ?? []).every((condition) => {
|
||||
@@ -364,7 +408,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
|
||||
<Switch>
|
||||
<Match when={item()?.field.options === undefined}>
|
||||
<TextField
|
||||
@@ -422,7 +466,13 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
// The API key path for the Console is an escape hatch below the sign-in flow, so
|
||||
// "back" returns to the sign-in rather than leaving the provider.
|
||||
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
|
||||
void controller.auth.select(oauthIndex())
|
||||
return
|
||||
}
|
||||
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
return
|
||||
}
|
||||
@@ -463,6 +513,31 @@ function ProviderConnection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusRow(input: { children: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span>{input.children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorRow() {
|
||||
return (
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
|
||||
<span role="alert">
|
||||
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="neutral" onClick={() => controller.auth.retry()}>
|
||||
{language.t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiAuthView() {
|
||||
let apiKey: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
@@ -494,22 +569,17 @@ function ProviderConnection(props: {
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Show
|
||||
when={provider().id === "opencode"}
|
||||
when={isConsole()}
|
||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||
<div>
|
||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/zen"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
<div>
|
||||
{language.t("provider.connect.console.apiKey.description")}{" "}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/console"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.link")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
@@ -574,13 +644,10 @@ function ProviderConnection(props: {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
|
||||
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
|
||||
{language.t("provider.connect.oauth.openBrowser")}
|
||||
</Button>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
||||
@@ -613,34 +680,60 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = controller.authorization()?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t(
|
||||
isConsole() ? "provider.connect.console.description" : "provider.connect.oauth.auto.description",
|
||||
{ provider: provider().name },
|
||||
)}
|
||||
</div>
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
class="font-mono"
|
||||
value={code()}
|
||||
readOnly
|
||||
copyable
|
||||
/>
|
||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
|
||||
{language.t("provider.connect.oauth.openBrowser")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={code()}>
|
||||
{(value) => (
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
|
||||
class="font-mono"
|
||||
value={value()}
|
||||
readOnly
|
||||
copyable
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately quiet: most people should never need a key, so it only appears after
|
||||
// opening "Advanced".
|
||||
function ConsoleAdvanced() {
|
||||
const [store, setStore] = createStore({ open: false })
|
||||
return (
|
||||
<div class="mt-auto flex flex-col items-end gap-1 px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xs px-1 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
aria-expanded={store.open}
|
||||
onClick={() => setStore("open", (open) => !open)}
|
||||
>
|
||||
{language.t("provider.connect.console.advanced")}
|
||||
</button>
|
||||
<Show when={store.open}>
|
||||
<button
|
||||
type="button"
|
||||
data-action="provider-connect-api-key"
|
||||
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.switch")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -661,53 +754,42 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div>
|
||||
<Switch>
|
||||
<Match when={controller.loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={controller.busy()}>
|
||||
<div class="px-3">
|
||||
<StatusRow>
|
||||
{language.t(
|
||||
isConsole() && controller.methodIndex() !== undefined
|
||||
? "provider.connect.console.opening"
|
||||
: "provider.connect.status.inProgress",
|
||||
)}
|
||||
</StatusRow>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<div class="px-3">
|
||||
<ErrorRow />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={isConsole() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}>
|
||||
<ConsoleAdvanced />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const dict = {
|
||||
"command.project.index": "Switch to project {{index}}",
|
||||
"command.project.copyID": "Copy Project ID",
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
"command.session.previous": "Previous session",
|
||||
@@ -225,6 +226,22 @@ export const dict = {
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" and enter the code below to connect your account and use {{provider}} models in OpenCode.",
|
||||
"provider.connect.oauth.auto.confirmationCode": "Confirmation code",
|
||||
"provider.connect.oauth.auto.confirmationCode.description":
|
||||
"Check that your browser shows the same code before you authorize.",
|
||||
"provider.connect.oauth.auto.description":
|
||||
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
|
||||
"provider.connect.oauth.code.description":
|
||||
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
|
||||
"provider.connect.oauth.openBrowser": "Open browser",
|
||||
"provider.connect.oauth.expired": "Authorization expired",
|
||||
"provider.connect.console.description":
|
||||
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
|
||||
"provider.connect.console.opening": "Opening your browser…",
|
||||
"provider.connect.console.advanced": "Advanced",
|
||||
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
|
||||
"provider.connect.console.apiKey.description":
|
||||
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
|
||||
"provider.connect.console.apiKey.link": "Open the Console",
|
||||
"provider.connect.toast.connected.title": "{{provider}} connected",
|
||||
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
||||
|
||||
@@ -302,6 +319,7 @@ export const dict = {
|
||||
"common.connect": "Connect",
|
||||
"common.disconnect": "Disconnect",
|
||||
"common.continue": "Continue",
|
||||
"common.retry": "Try again",
|
||||
"common.submit": "Submit",
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving…",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServerHealth } from "@/runtime/server/health"
|
||||
@@ -98,6 +98,25 @@ function createGlobalModels() {
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
// Marks models visible in the picker regardless of the "latest per family" default.
|
||||
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
|
||||
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
|
||||
batch(() => {
|
||||
for (const model of models) {
|
||||
const index = seen.get(`${model.providerID}:${model.modelID}`)
|
||||
if (index !== undefined) {
|
||||
setStore("user", index, "visibility", "show")
|
||||
continue
|
||||
}
|
||||
seen.set(`${model.providerID}:${model.modelID}`, store.user.length)
|
||||
setStore("user", store.user.length, {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
visibility: "show",
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user