import { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import type { ConnectionInfo, IntegrationCommandConnectOutput, IntegrationInfo, IntegrationOauthConnectOutput, IntegrationOAuthMethod, FormAnswer, FormField, FormFields, FormValue, LocationRef, } from "@opencode/client" import { openUrl } from "@opencode/util/open" import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { useNewDesigns } from "../config" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" import { useLocation } from "../context/location" import { useTheme } from "../context/theme" import { DIALOG_INSET, DIALOG_LIST_INSET, DialogBleed, DialogFooter, DialogHeader, DialogLayout, 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" import { errorMessage } from "../util/error" import { formInitialValues, formLabel, formNumberValue, formRows, formSelected, formToggleMultiselect, formValidateValue, type FormAnswerField, } from "../util/form" const INTEGRATION_PRIORITY: Record = { "opencode-go": 0, opencode: 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 const CANCELLED = Symbol("cancelled") const CUSTOM = Symbol("custom") const OPEN = Symbol("open") const SUBMIT = Symbol("submit") // Device-flow codes such as "R283-GOUEO"; when present they are copied instead of the URL. const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/ export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( (a, b) => Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") || (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", ) } // A count keeps several long labels from squeezing the integration name off the row. export function connectionSummary(integration: IntegrationInfo) { const connections = integration.connections if (connections.length > 1) return `${connections.length} connections` const connection = connections[0] if (!connection) return "" return connection.type === "credential" ? connection.label : `$${connection.name}` } export function DialogIntegration( props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {}, ) { const data = useData() const currentLocation = useLocation() const dialog = useDialog() const theme = useTheme().surface("dialog") const location = currentLocation.ref ?? data.location.default() const integrations = createMemo(() => integrationOptions(data.location.integration.list(location) ?? []).filter( (integration) => props.integrationID === undefined || integration.id === props.integrationID, ), ) createEffect(() => { if (!props.autoConnect) return const integration = integrations()[0] if (!integration) return const methods = connectMethods(integration) if (credentialConnections(integration).length) { manageConnections(integration, methods, location, dialog, props.onConnected) return } selectMethod(integration, methods, location, dialog, props.onConnected) }) const options = createMemo(() => { return integrations().map((integration) => { const methods = connectMethods(integration) const credentials = credentialConnections(integration) let category = "Services" if (integration.id in INTEGRATION_PRIORITY) category = "Popular" if (integration.metadata?.source === "mcp") category = "MCP" const status = integration.connections[0]?.status return { title: integration.name, value: integration.id, description: methods.length === 0 ? "Environment only" : undefined, footer: status ? "Sign in required →" : connectionSummary(integration) || undefined, footerColor: status ? theme.text.feedback.warning.base : undefined, category, disabled: methods.length === 0 && credentials.length === 0, gutter: integration.connections.length > 0 && !status ? () => ✓ : undefined, onSelect: () => { if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected) return selectMethod(integration, methods, location, dialog, props.onConnected) }, } }) }) return ( No integrations available } noMatchView={ No integrations found } /> ) } function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { dialog.replace(() => { const data = useData() const client = useClient() const toast = useToast() const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() const newDesigns = useNewDesigns() const [deleting, setDeleting] = createSignal() const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id) const current = createMemo(() => data.location.integration.list(location)?.find((item) => item.id === integration.id), ) return ( { setSelected(option.value) setDeleting(undefined) }} options={[ ...(methods.length ? [ { title: "Add account", value: "add", onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected), }, ] : []), ...credentialConnections(current() ?? integration) .toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)) .map((connection) => { const confirming = deleting() === connection.id return { title: confirming ? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm` : connection.label, value: connection.id, footer: connection.status ? "Sign in required →" : undefined, footerColor: connection.status ? theme.text.feedback.warning.base : undefined, category: "Connected accounts", bg: confirming ? theme.background.action.destructive.focused : undefined, fg: confirming ? theme.text.action.destructive.focused : undefined, onSelect: () => { if (connection.status?.url) return void openUrl(connection.status.url).catch(toast.error) if (connection.status) return selectMethod(current() ?? integration, methods, location, dialog, onConnected) if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error) }, } }), ]} actions={[ { command: "dialog.integration.rename", title: "rename", hidden: selected() === "add", disabled: (option) => !option || option.value === "add", onTrigger: (option) => { dialog.replace(() => ( item.id === option.value)?.label } onConfirm={(value) => { const label = value.trim() if (!label) return void client.api.credential .update({ credentialID: option.value, label }) .then(() => manageConnections(integration, methods, location, dialog, onConnected)) .catch(toast.error) }} /> )) }, }, { command: "dialog.integration.delete", title: newDesigns() ? "remove account" : "delete", hidden: selected() === "add", disabled: (option) => !option || option.value === "add", onTrigger: (option) => { if (deleting() !== option.value) return setDeleting(option.value) const final = credentialConnections(current() ?? integration).length === 1 void client.api.credential .remove({ credentialID: option.value }) .then(() => { setDeleting(undefined) if (!final) return toast.show({ variant: "success", message: `Disconnected ${integration.name}` }) dialog.clear() }) .catch((error) => { setDeleting(undefined) toast.error(error) }) }, }, ]} /> ) }) } function selectMethod( integration: IntegrationInfo, methods: ConnectMethod[], location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected) dialog.replace(() => { const newDesigns = useNewDesigns() return ( ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, onSelect: () => openMethod(integration, method, location, dialog, onConnected), }))} /> ) }) } function openMethod( integration: IntegrationInfo, method: ConnectMethod, location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { if (method.type === "key") { void beginKey(integration, method, location, dialog, onConnected) return } if (method.type === "command") { dialog.replace(() => ( )) return } if (method.type === "external") { void beginExternal(integration, method, location, dialog, onConnected) return } void beginOAuth(integration, method, location, dialog, onConnected) } async function beginExternal( integration: IntegrationInfo, method: Extract, location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined if (answer === null) return dialog.replace(() => ( )) } function ExternalStarting(props: { integration: IntegrationInfo method: Extract location: LocationRef answer?: FormAnswer onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() onMount(() => { void client.api.integration.connect .external({ integrationID: props.integration.id, location: locationQuery(props.location), methodID: props.method.id, ...(props.answer ? { answer: props.answer } : {}), }) .then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected)) .catch((cause) => { toast.show({ variant: "error", message: errorMessage(cause) }) dialog.clear() }) }) return } async function beginKey( integration: IntegrationInfo, method: Extract, location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { const answer = method.form ? await formAnswer(dialog, method.label ?? `Connect ${integration.name}`, method.form) : undefined if (answer === null) return dialog.replace(() => ( )) } function CommandStarting(props: { integration: IntegrationInfo method: Extract location: LocationRef onConnected?: OnIntegrationConnected }) { 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: locationQuery(props.location), }) .then((result) => { if (closed) { void client.api.integration.command.cancel({ integrationID: props.integration.id, attemptID: result.data.attemptID, location: locationQuery(props.location), }) return } handedOff = true dialog.replace(() => ( )) }) .catch((cause) => { if (closed) return toast.show({ variant: "error", message: errorMessage(cause) }) dialog.clear() }) }) onCleanup(() => { if (!handedOff) closed = true }) return } function CommandPending(props: { integration: IntegrationInfo title: string attempt: CommandAttempt location: LocationRef 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: locationQuery(props.location), }) .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, props.location, 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: errorMessage(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: locationQuery(props.location), }) }) return } function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() const theme = useTheme().surface("dialog") const overlayTheme = useTheme() onMount(() => dialog.setSize("large")) return ( }> {props.output.trim()} {props.message} ) } function KeyMethod(props: { integration: IntegrationInfo method: Extract location: LocationRef answer?: FormAnswer onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const theme = useTheme().surface("dialog") const [error, setError] = createSignal() return ( { if (!key) return void client.api.integration.connect .key({ integrationID: props.integration.id, location: locationQuery(props.location), key, ...(props.answer ? { answer: props.answer } : {}), }) .then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected)) .catch((cause) => setError(errorMessage(cause))) }} description={() => ( {(value) => {value()}} )} /> ) } async function beginOAuth( integration: IntegrationInfo, method: IntegrationOAuthMethod, location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined if (answer === null) return dialog.replace(() => ( )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod location: LocationRef answer?: FormAnswer onConnected?: OnIntegrationConnected }) { const dialog = useDialog() const client = useClient() const toast = useToast() onMount(() => { void client.api.integration.oauth .connect({ integrationID: props.integration.id, location: locationQuery(props.location), methodID: props.method.id, ...(props.answer ? { answer: props.answer } : {}), }) .then((result) => { if (result.data.mode === "code") { dialog.replace(() => ( )) return } dialog.replace(() => ( )) }) .catch((cause) => { toast.show({ variant: "error", message: errorMessage(cause) }) dialog.clear() }) }) return } function OAuthAuto(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt location: LocationRef onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const clipboard = useClipboard() const newDesigns = useNewDesigns() let timer: ReturnType | undefined let settled = false const open = () => { openUrl(props.attempt.url).catch(() => toast.show({ message: "Could not open the browser. Copy the URL and continue manually.", variant: "error", }), ) } const copy = () => { const value = props.attempt.instructions.match(OAUTH_CODE)?.[0] ?? props.attempt.url clipboard .write(value) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) } Keymap.createLayer(() => ({ mode: "modal", commands: [ { bind: newDesigns() ? "return" : "o", title: "Open authorization URL", group: "Dialog", run: open }, { bind: "c", title: "Copy authorization details", group: "Dialog", run: copy }, ], })) const poll = () => { void client.api.integration.oauth .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: locationQuery(props.location), }) .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, props.location, 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: errorMessage(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: locationQuery(props.location), }) }) return ( ) } function OAuthCode(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt location: LocationRef onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() const theme = useTheme().surface("dialog") const newDesigns = useNewDesigns() 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: locationQuery(props.location), }) }) return ( { if (!code) return void client.api.integration.oauth .complete({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: locationQuery(props.location), code, }) .then(() => { settled = true return connected(props.integration, props.location, data, dialog, toast, props.onConnected) }) .catch((cause) => setError(errorMessage(cause))) }} description={() => ( {props.attempt.instructions} {(value) => {value()}} )} /> ) } function OAuthView(props: { title: string url?: string instructions?: string message: string onCopy?: () => void onOpen?: () => void }) { const theme = useTheme().surface("dialog") return ( } footer={{ actions: { primary: [ ...(props.onCopy ? [ { title: props.instructions?.match(OAUTH_CODE) ? "copy code" : "copy link", key: "c", onTrigger: props.onCopy, }, ] : []), ...(props.onOpen ? [{ title: "open link", key: "enter", onTrigger: props.onOpen }] : []), ], }, }} legacyFooter={ } > {(url) => ( {(instructions) => {instructions()}} )} {props.message} ) } async function formAnswer(dialog: ReturnType, title: string, fields: FormFields) { const answer: FormAnswer = {} for (const field of fields) { if (!active(field, answer)) continue const value = field.type !== "external" && field.hidden ? field.default : await fieldAnswer(dialog, title, field) if (value === CANCELLED) return null if (value !== undefined) answer[field.key] = value } return answer } function active(field: FormField, answer: FormAnswer) { if (field.type === "external" || !field.when) return true return field.when.every((when) => { const value = answer[when.key] if (value === undefined) return false const hit = Array.isArray(value) ? value.includes(String(when.value)) : value === when.value return when.op === "eq" ? hit : !hit }) } function fieldAnswer( dialog: ReturnType, title: string, field: FormField, ): Promise { if (field.type === "external") return externalAnswer(dialog, title, field) if (field.type === "multiselect") return multiselectAnswer(dialog, title, field) if (field.type === "boolean" || (field.type === "string" && field.options)) { return selectAnswer(dialog, title, field) } return textAnswer(dialog, title, field) } async function selectAnswer( dialog: ReturnType, title: string, field: Extract, ): Promise { if (field.type === "string" && field.custom) return new Promise((resolve) => { dialog.replace( () => , () => resolve(CANCELLED), ) }) const options = field.type === "boolean" ? field.default === false ? [ { title: "No", value: false as FormValue }, { title: "Yes", value: true as FormValue }, ] : [ { title: "Yes", value: true as FormValue }, { title: "No", value: false as FormValue }, ] : (field.options ?? []).map((option) => ({ title: option.label, value: option.value as FormValue, description: option.description, })) return new Promise((resolve) => { dialog.replace( () => ( title={formLabel(field) || title} options={[...options, ...(!field.required ? [{ title: "Skip", value: undefined }] : [])]} current={field.type === "string" ? field.default : undefined} onSelect={(option) => resolve(option.value)} /> ), () => resolve(CANCELLED), ) }) } function StringChoiceField(props: { field: Extract onSubmit: (value: string | undefined) => void }) { const dialog = useDialog() const theme = useTheme().surface("dialog") const dimensions = useTerminalDimensions() const rows = formRows(props.field) const count = rows.length + (props.field.required ? 1 : 2) const [store, setStore] = createStore({ selected: formSelected(props.field, props.field.default), editing: false, text: formInitialValues([props.field]).custom[props.field.key] ?? "", error: "", }) const [textarea, setTextarea] = createSignal() let scroll: ScrollBoxRenderable | undefined createEffect(() => { const row = scroll?.getChildren()[store.selected] if (row) scroll?.scrollChildIntoView(row.id) }) const fg = (index: number) => (store.selected === index ? theme.text.formfield.focused : theme.text.formfield.base) const submit = (value: string | undefined) => { const invalid = formValidateValue(props.field, value) if (invalid) return setStore("error", invalid) props.onSubmit(value) } const select = (index: number) => { setStore({ selected: index, error: "" }) if (index === rows.length) return setStore("editing", true) submit(index < rows.length ? String(rows[index].value) : undefined) } const back = () => setStore({ editing: false, text: textarea()?.plainText ?? store.text, error: "" }) Keymap.createLayer(() => ({ mode: "modal", enabled: !store.editing, commands: [ { id: "dialog.select.prev", title: "Previous answer", group: "Form", run: () => setStore("selected", (store.selected + count - 1) % count), }, { id: "dialog.select.next", title: "Next answer", group: "Form", run: () => setStore("selected", (store.selected + 1) % count), }, { id: "dialog.select.submit", title: "Select answer", group: "Form", run: () => select(store.selected) }, ], })) Keymap.createLayer(() => ({ mode: "modal", priority: 1, target: textarea, enabled: store.editing, commands: [ { id: "dialog.prompt.submit", title: "Submit answer", group: "Form", run: () => submit(textarea()?.plainText.trim() || undefined), }, { bind: "escape", title: "Back to answers", group: "Form", run: back }, ], })) return ( (store.editing ? back() : dialog.clear())} />} footer={{ actions: { secondary: store.editing ? [ { title: "submit", key: "enter", onTrigger: () => submit(textarea()?.plainText.trim() || undefined) }, { title: "back", key: "esc", onTrigger: back }, ] : [ { title: "select", key: "↑/↓" }, { title: "confirm", key: "enter", onTrigger: () => select(store.selected) }, { title: "cancel", key: "esc", onTrigger: () => dialog.clear() }, ], }, }} > {(description) => {description()}} row.description).length, Math.max(3, Math.floor(dimensions().height / 2) - 6), )} scrollbarOptions={{ visible: false }} ref={(value: ScrollBoxRenderable) => { scroll = value }} > {(row, index) => ( select(index())}> {index() + 1}. {row.label} {(description) => ( {description()} )} )} select(rows.length)}> {rows.length + 1}. {store.text || "Type your own answer"}} >