import { TextAttributes } from "@opentui/core" import type { ConnectionInfo, IntegrationCommandConnectOutput, IntegrationInfo, IntegrationOauthConnectOutput, IntegrationOAuthMethod, } from "@opencode-ai/client" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" import { Link } from "../ui/link" import { useToast } from "../ui/toast" const INTEGRATION_PRIORITY: Record = { opencode: 0, "opencode-go": 1, openai: 2, "github-copilot": 3, anthropic: 4, google: 5, } type ConnectMethod = Exclude type IntegrationAttempt = IntegrationOauthConnectOutput["data"] type CommandAttempt = IntegrationCommandConnectOutput["data"] type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( (a, b) => (INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id), ) } export function connectMethods(integration: IntegrationInfo): ConnectMethod[] { return integration.methods .filter((method): method is ConnectMethod => method.type !== "env") .toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key")) } export function credentialConnections(integration: IntegrationInfo) { return integration.connections.filter( (connection): connection is Extract => connection.type === "credential", ) } export function connectionSummary(integration: IntegrationInfo) { return integration.connections .map((connection) => (connection.type === "credential" ? connection.label : `$${connection.name}`)) .join(", ") } export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) { const data = useData() const dialog = useDialog() const { themeV2 } = useTheme().contextual("elevated") const options = createMemo(() => integrationOptions(data.location.integration.list() ?? []).map((integration) => { const methods = connectMethods(integration) const connected = integration.connections.length > 0 return { title: integration.name, value: integration.id, description: methods.length ? undefined : "Environment only", footer: connectionSummary(integration) || undefined, category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services", disabled: methods.length === 0, gutter: connected ? () => : undefined, onSelect: () => credentialConnections(integration).length ? manageConnections(integration, methods, dialog, props.onConnected) : selectMethod(integration, methods, dialog, props.onConnected), } }), ) return ( No integrations available } noMatchView={ No integrations found } /> ) } function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { dialog.replace(() => { const data = useData() const client = useClient() const toast = useToast() return ( selectMethod(integration, methods, dialog, onConnected), }, ] : []), ...credentialConnections(integration).map((connection) => ({ title: `Disconnect ${connection.label}`, value: connection.id, onSelect: () => { void client.api.credential .remove({ credentialID: connection.id, location: location(data) }) .then(() => disconnected(integration.name, data, dialog, toast)) .catch(toast.error) }, })), ]} /> ) }) } function selectMethod( integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected) dialog.replace(() => ( ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, onSelect: () => openMethod(integration, method, dialog, onConnected), }))} /> )) } function openMethod( integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { if (method.type === "key") { dialog.replace(() => ) return } if (method.type === "command") { dialog.replace(() => ) return } void beginOAuth(integration, method, dialog, onConnected) } function CommandStarting(props: { integration: IntegrationInfo method: Extract onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() let closed = false let handedOff = false onMount(() => { void client.api.integration.command .connect({ integrationID: props.integration.id, methodID: props.method.id, location: location(data), }) .then((result) => { if (closed) { void client.api.integration.command.cancel({ integrationID: props.integration.id, attemptID: result.data.attemptID, location: location(data), }) return } handedOff = true dialog.replace(() => ( )) }) .catch((cause) => { if (closed) return toast.show({ variant: "error", message: message(cause) }) dialog.clear() }) }) onCleanup(() => { if (!handedOff) closed = true }) return } function CommandPending(props: { integration: IntegrationInfo title: string attempt: CommandAttempt onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const [output, setOutput] = createSignal("") let timer: ReturnType | undefined let settled = false const poll = () => { void client.api.integration.command .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data), }) .then((result) => { const status = result.data if (status.status === "pending") { setOutput(status.message ?? "") timer = setTimeout(poll, 500) return } settled = true if (status.status === "complete") { void connected(props.integration, data, dialog, toast, props.onConnected) return } toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authentication expired", }) dialog.clear() }) .catch((cause) => { settled = true toast.show({ variant: "error", message: message(cause) }) dialog.clear() }) } onMount(poll) onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return void client.api.integration.command.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data), }) }) return } function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() const { themeV2 } = useTheme().contextual("elevated") const { themeV2: overlayTheme } = useTheme().contextual("overlay") onMount(() => dialog.setSize("large")) return ( {props.title} dialog.clear()}> esc close {props.output.trim()} {props.message} ) } function KeyMethod(props: { integration: IntegrationInfo method: Extract onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const { themeV2 } = useTheme().contextual("elevated") const [error, setError] = createSignal() return ( { if (!key) return void client.api.integration.connect .key({ integrationID: props.integration.id, location: location(data), key, }) .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) }} description={() => ( {(value) => {value()}} )} /> ) } async function beginOAuth( integration: IntegrationInfo, method: IntegrationOAuthMethod, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} if (inputs === null) return dialog.replace(() => ( )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod inputs: Record onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() onMount(() => { void client.api.integration.oauth .connect({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, inputs: props.inputs, }) .then((result) => { if (result.data.mode === "code") { dialog.replace(() => ( )) return } dialog.replace(() => ( )) }) .catch((cause) => { toast.show({ variant: "error", message: message(cause) }) dialog.clear() }) }) return } function OAuthAuto(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const clipboard = useClipboard() let timer: ReturnType | undefined let settled = false Keymap.createLayer(() => ({ mode: "modal", commands: [ { bind: "c", title: "Copy authorization details", group: "Dialog", run: () => { const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url clipboard .write?.(value) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) }, }, ], })) const poll = () => { void client.api.integration.oauth .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { timer = setTimeout(poll, 500) return } settled = true if (status.status === "complete") { void connected(props.integration, data, dialog, toast, props.onConnected) return } toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) dialog.clear() }) .catch((cause) => { settled = true toast.show({ variant: "error", message: message(cause) }) dialog.clear() }) } onMount(poll) onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return void client.api.integration.oauth.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data), }) }) return ( ) } function OAuthCode(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const { themeV2 } = useTheme().contextual("elevated") const [error, setError] = createSignal() let settled = false onCleanup(() => { if (settled) return void client.api.integration.oauth.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data), }) }) return ( { if (!code) return void client.api.integration.oauth .complete({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data), code, }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) }) .catch((cause) => setError(message(cause))) }} description={() => ( {props.attempt.instructions} {(value) => {value()}} )} /> ) } function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) { const dialog = useDialog() const { themeV2 } = useTheme().contextual("elevated") return ( {props.title} dialog.clear()}> esc {(url) => ( {(instructions) => {instructions()}} )} {props.message} c copy ) } async function promptInputs( dialog: ReturnType, prompts: NonNullable, ) { const inputs: Record = {} for (const prompt of prompts) { if (prompt.when) { const value = inputs[prompt.when.key] if (value === undefined) continue const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value if (!matches) continue } if (prompt.type === "select") { const value = await new Promise((resolve) => { dialog.replace( () => ( ({ title: option.label, value: option.value, description: option.hint, }))} onSelect={(option) => resolve(option.value)} /> ), () => resolve(null), ) }) if (value === null) return null inputs[prompt.key] = value continue } const value = await new Promise((resolve) => { dialog.replace( () => , () => resolve(null), ) }) if (value === null) return null inputs[prompt.key] = value } return inputs } async function connected( integration: IntegrationInfo, data: ReturnType, dialog: ReturnType, toast: ReturnType, onConnected?: OnIntegrationConnected, ) { data.location.integration.invalidate() data.location.model.invalidate() data.location.provider.invalidate() await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Connected ${integration.name}` }) if (onConnected) { onConnected(providerID(data, integration.id)) return } dialog.clear() } function providerID(data: ReturnType, integrationID: string) { const models = data.location.model.list() ?? [] const matches = (data.location.provider.list() ?? []).filter( (provider) => provider.integrationID === integrationID || provider.id === integrationID, ) return ( matches.find((provider) => models.some((model) => model.providerID === provider.id && model.status !== "deprecated"), )?.id ?? matches[0]?.id ) } async function disconnected( name: string, data: ReturnType, dialog: ReturnType, toast: ReturnType, ) { data.location.integration.invalidate() data.location.model.invalidate() data.location.provider.invalidate() await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) toast.show({ variant: "success", message: `Disconnected ${name}` }) dialog.clear() } function location(data: ReturnType) { const current = data.location.default() return { directory: current.directory, workspace: current.workspaceID } } function message(cause: unknown) { if (cause instanceof Error) return cause.message return "Authentication failed" }