import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import { type Accessor, type Component, createEffect, createMemo, createResource, createUniqueId, For, Match, onCleanup, onMount, Show, Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" import { useParams } from "@solidjs/router" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" import { decode64 } from "@/utils/base64" const CUSTOM_ID = "_custom" type ConnectMethod = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) const reset = () => setStore("selected", undefined) return { selected: () => store.selected, select: (provider?: string) => setStore("selected", provider), back: options.onBack ?? reset, } } export const DialogConnectProvider: Component<{ directory?: Accessor controller?: ReturnType }> = (props) => { const fallback = useProviderConnectController() const controller = props.controller ?? fallback const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const reset = controller.back const back = { current: reset } let focusHost: HTMLDivElement | undefined const holdFocus = () => focusHost?.focus({ preventScroll: true }) const select = (provider?: string) => { back.current = reset controller.select(provider) } function Content() { return ( {(provider) => ( (back.current = handler)} /> )} ) } return ( back.current()} aria-label={language.t("common.goBack")} /> } > } > {language.t("command.provider.connect")}} >
) } function ProviderPicker(props: { directory?: Accessor onSelect: (provider: string) => void onPrepare?: () => void }) { const settings = useSettings() if (settings.general.newLayoutDesigns()) return const providers = useProviders(() => props.directory?.()) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") const customLabel = () => language.t("settings.providers.tag.custom") const note = (id: string) => { if (id === "anthropic") return language.t("dialog.provider.anthropic.note") if (id === "openai") return language.t("dialog.provider.openai.note") if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") return undefined } return ( x?.id} items={() => { language.locale() return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} sortBy={(a, b) => { if (a.id === CUSTOM_ID) return -1 if (b.id === CUSTOM_ID) return 1 if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) return a.name.localeCompare(b.name) }} sortGroupsBy={(a, b) => { const popular = popularGroup() if (a.category === popular && b.category !== popular) return -1 if (b.category === popular && a.category !== popular) return 1 return 0 }} onSelect={(x) => { if (!x) return props.onSelect(x.id) }} > {(i) => (
{i.name}
{language.t("dialog.provider.opencode.tagline")}
{language.t("settings.providers.tag.custom")} {language.t("dialog.provider.tag.recommended")} {(value) =>
{value()}
}
{language.t("dialog.provider.tag.recommended")}
)}
) } function ProviderPickerV2(props: { directory?: Accessor onSelect: (provider: string) => void onPrepare?: () => void }) { const providers = useProviders(() => props.directory?.()) const language = useLanguage() const [store, setStore] = createStore({ filter: "", active: undefined as string | undefined, connecting: undefined as string | undefined, }) const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() const values = [custom(), ...providers.all().values()] if (!query) return values return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) }) const popular = createMemo(() => all() .filter((provider) => featured.includes(provider.id)) .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), ) const other = createMemo(() => all() .filter((provider) => !featured.includes(provider.id)) .sort((a, b) => { if (a.id === CUSTOM_ID) return -1 if (b.id === CUSTOM_ID) return 1 return a.name.localeCompare(b.name) }), ) const rows = createMemo(() => [...popular(), ...other()]) let picker: HTMLDivElement | undefined let search: HTMLInputElement | undefined onMount(() => search?.focus({ preventScroll: true })) const connect = (provider: string) => { props.onPrepare?.() props.onSelect(provider) } const move = (event: KeyboardEvent, direction: number) => { const items = rows() if (items.length === 0) return const index = items.findIndex((provider) => provider.id === store.active) const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length setStore("active", items[next].id) picker ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) ?.focus({ preventScroll: true }) event.preventDefault() } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown") return move(event, 1) if (event.key === "ArrowUp") return move(event, -1) if (event.key !== "Enter" || !store.active) return connect(store.active) event.preventDefault() } return (
} placeholder={language.t("dialog.provider.search.placeholder")} value={store.filter} onInput={(event) => { setStore({ filter: event.currentTarget.value, active: undefined }) }} />
{(group) => ( 0}>
{group.title}
{(provider) => ( )}
)}
{language.t("dialog.provider.empty")}
) } function ProviderConnection(props: { provider: string directory?: Accessor onBack: () => void setBack: (handler: () => void) => void }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const params = useParams() const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(() => props.directory?.()) const directory = () => props.directory?.() ?? decode64(params.dir) const location = () => { const value = directory() return value ? { directory: value } : undefined } const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } onCleanup(() => { alive.value = false if (timer.current === undefined) return clearTimeout(timer.current) timer.current = undefined }) const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) const fallback = createMemo(() => [ { type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) const [integration] = createResource( () => ({ provider: props.provider, directory: directory() }), (input) => serverSDK() .api.integration.get({ integrationID: input.provider, location: input.directory ? { directory: input.directory } : undefined, }) .then((result) => result.data), ) const loading = createMemo(() => integration.loading) const methods = createMemo(() => { const values = integration.latest?.methods.filter( (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", ) return values?.length ? values : fallback() }) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) type Action = | { type: "method.select"; index: number } | { type: "method.reset" } | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { setStore( produce((draft) => { if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined draft.promptInputs = undefined draft.state = undefined draft.error = undefined return } if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined draft.promptInputs = undefined draft.state = undefined draft.error = undefined return } if (action.type === "auth.prompt") { draft.state = "prompt" draft.error = undefined return } if (action.type === "auth.inputs") { draft.promptInputs = action.inputs draft.state = undefined draft.error = undefined return } if (action.type === "auth.pending") { draft.state = "pending" draft.error = undefined return } if (action.type === "auth.complete") { draft.state = "complete" draft.authorization = action.authorization draft.error = undefined return } draft.state = "error" draft.error = action.error }), ) } const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" if (value.type === "key") return language.t("provider.connect.method.apiKey") return value.label ?? "" } const methodDetails = (value?: { type?: string; label?: string }) => { const label = methodLabel(value) const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, } } function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data if (typeof data?.message === "string" && data.message) return data.message } if (value && typeof value === "object" && "error" in value) { const nested = formatError((value as { error?: unknown }).error, "") if (nested) return nested } if (value && typeof value === "object" && "message" in value) { const message = (value as { message?: unknown }).message if (typeof message === "string" && message) return message } if (value instanceof Error && value.message) return value.message if (typeof value === "string" && value) return value return fallback } async function selectMethod(index: number, inputs?: Record) { if (timer.current !== undefined) { clearTimeout(timer.current) timer.current = undefined } const method = methods()[index] dispatch({ type: "method.select", index }) if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) await serverSDK() .api.integration.oauth.connect({ integrationID: props.provider, methodID: method.id, inputs: inputs ?? {}, location: location(), }) .then((x) => { if (!alive.value) return dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) }) } } function AuthPromptsView() { const [formStore, setFormStore] = createStore({ value: {} as Record, index: 0, }) const prompts = createMemo(() => { const value = method() return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] if (actual === undefined) return false return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value } const current = createMemo(() => { const all = prompts() const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) if (index === -1) return return { index, prompt: all[index], } }) const valid = createMemo(() => { const item = current() if (!item || item.prompt.type !== "text") return false const value = formStore.value[item.prompt.key] ?? "" return value.trim().length > 0 }) async function next(index: number, value: Record) { if (store.methodIndex === undefined) return const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) if (next !== -1) { setFormStore("index", next) return } await selectMethod(store.methodIndex, value) } async function handleSubmit(e: SubmitEvent) { e.preventDefault() const item = current() if (!item || item.prompt.type !== "text") return if (!valid()) return await next(item.index, formStore.value) } const item = () => current() const text = createMemo(() => { const prompt = item()?.prompt if (!prompt || prompt.type !== "text") return return prompt }) const select = createMemo(() => { const prompt = item()?.prompt if (!prompt || prompt.type !== "select") return return prompt }) return (
{ const prompt = text() if (!prompt) return setFormStore("value", prompt.key, value) }} />
{select()?.message}
x.value} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} onSelect={(value) => { if (!value) return const prompt = select() if (!prompt) return const nextValue = { ...formStore.value, [prompt.key]: value.value, } setFormStore("value", prompt.key, value.value) void next(item()!.index, nextValue) }} > {(option) => (
{option.label} {option.hint}
)}
) } let listRef: ListRef | undefined function handleKey(e: KeyboardEvent) { if (e.key === "Enter" && e.target instanceof HTMLInputElement) { return } if (e.key === "Escape") return listRef?.onKeyDown(e) } let auto = false createEffect(() => { if (auto) return if (loading()) return if (methods().length === 1) { auto = true void selectMethod(0) } }) async function complete() { await serverSync() .refreshProviders() .catch(() => undefined) dialog.close() showToast({ variant: "success", icon: "circle-check", title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), }) } function goBack() { if (methods().length > 1 && store.methodIndex !== undefined) { dispatch({ type: "method.reset" }) return } props.onBack() } props.setBack(goBack) function MethodSelection() { if (newLayout()) return (
{language.t("provider.connect.selectMethod", { provider: provider().name })}
{(item, index) => { const details = () => methodDetails(item) return ( ) }}
) return ( <>
{language.t("provider.connect.selectMethod", { provider: provider().name })}
{ listRef = ref }} items={methods} key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) }} > {(i) => (
{methodLabel(i)}
)}
) } function ApiAuthView() { let apiKey: HTMLInputElement | undefined const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) onMount(() => { if (!newLayout()) return apiKey?.focus({ preventScroll: true }) }) async function handleSubmit(e: SubmitEvent) { e.preventDefault() const form = e.currentTarget as HTMLFormElement const formData = new FormData(form) const apiKey = formData.get("apiKey") as string if (!apiKey?.trim()) { setFormStore("error", language.t("provider.connect.apiKey.required")) return } setFormStore("error", undefined) await serverSDK().api.integration.connect.key({ integrationID: props.provider, location: location(), key: apiKey, }) await complete() } if (newLayout()) return (
{language.t("provider.connect.opencodeZen.line1")}
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.link")} {language.t("provider.connect.opencodeZen.visit.suffix")}
{(error) => ( )} {language.t("common.continue")}
) return (
{language.t("provider.connect.opencodeZen.line1")}
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.link")} {language.t("provider.connect.opencodeZen.visit.suffix")}
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
setFormStore("value", v)} validationState={formStore.error ? "invalid" : undefined} error={formStore.error} />
) } function OAuthCodeView() { let codeInput: HTMLInputElement | undefined const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) onMount(() => { if (!newLayout()) return codeInput?.focus({ preventScroll: true }) }) async function handleSubmit(e: SubmitEvent) { e.preventDefault() const form = e.currentTarget as HTMLFormElement const formData = new FormData(form) const code = formData.get("code") as string if (!code?.trim()) { setFormStore("error", language.t("provider.connect.oauth.code.required")) return } setFormStore("error", undefined) const result = await serverSDK() .api.integration.oauth.complete({ integrationID: props.provider, attemptID: store.authorization!.attemptID, location: location(), code, }) .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() return } setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } if (newLayout()) return (
{language.t("provider.connect.oauth.code.visit.prefix")} {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
{(error) => ( )} {language.t("common.continue")}
) return (
{language.t("provider.connect.oauth.code.visit.prefix")} {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
setFormStore("value", v)} validationState={formStore.error ? "invalid" : undefined} error={formStore.error} />
) } function OAuthAutoView() { const code = createMemo(() => { const instructions = store.authorization?.instructions if (instructions?.includes(":")) { return instructions.split(":").pop()?.trim() } return instructions }) onMount(() => { const poll = async () => { const authorization = store.authorization if (!authorization || !alive.value) return const result = await serverSDK() .api.integration.oauth.status({ integrationID: props.provider, attemptID: authorization.attemptID, location: location(), }) .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) if (!alive.value) return if (!result.ok) { dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } if (result.status.status === "complete") { await complete() return } if (result.status.status === "failed") { dispatch({ type: "auth.error", error: result.status.message }) return } if (result.status.status === "expired") { dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) return } timer.current = setTimeout(poll, 1_000) } void poll() }) return (
{language.t("provider.connect.oauth.auto.visit.prefix")} {language.t("provider.connect.oauth.auto.visit.link")} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
{language.t("provider.connect.status.waiting")}
) } return (
{language.t("provider.connect.title.anthropicProMax")} {language.t("provider.connect.title", { provider: provider().name })}
{language.t("provider.connect.status.inProgress")}
{language.t("provider.connect.status.inProgress")}
{language.t("provider.connect.status.failed", { error: store.error ?? "" })}
) }