opencode/packages/tui/src/component/dialog-integration.tsx

679 lines
21 KiB
TypeScript

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<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
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<ConnectionInfo, { type: "credential" }> => 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 ? () => <text fg={themeV2.text.feedback.success.default}></text> : undefined,
onSelect: () =>
credentialConnections(integration).length
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected),
}
}),
)
return (
<DialogSelect
title="Connect a service"
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={themeV2.text.subdued}>No integrations found</text>
</box>
}
/>
)
}
function manageConnections(
integration: IntegrationInfo,
methods: ConnectMethod[],
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
dialog.replace(() => {
const data = useData()
const client = useClient()
const toast = useToast()
return (
<DialogSelect
title={integration.name}
options={[
...(methods.length
? [
{
title: "Add connection",
value: "add",
onSelect: () => 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<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
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<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
return
}
if (method.type === "command") {
dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
return
}
void beginOAuth(integration, method, dialog, onConnected)
}
function CommandStarting(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "command" }>
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(() => (
<CommandPending
integration={props.integration}
title={props.method.label}
attempt={result.data}
onConnected={props.onConnected}
/>
))
})
.catch((cause) => {
if (closed) return
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
})
onCleanup(() => {
if (!handedOff) closed = true
})
return <CommandView title={props.method.label} output="" message="Starting command..." />
}
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<typeof setTimeout> | 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 <CommandView title={props.title} output={output()} message="Waiting for command to finish..." />
}
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 (
<box gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc close
</text>
</box>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<text fg={overlayTheme.text.default}>{props.output.trim()}</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={themeV2.text.subdued}>{props.message}</text>
</box>
</box>
)
}
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const [error, setError] = createSignal<string>()
return (
<DialogPrompt
title={props.method.label ?? `Connect ${props.integration.name}`}
placeholder="API key"
onConfirm={(key) => {
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={() => (
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
)}
/>
)
}
async function beginOAuth(
integration: IntegrationInfo,
method: IntegrationOAuthMethod,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => (
<OAuthStarting integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
}
function OAuthStarting(props: {
integration: IntegrationInfo
method: IntegrationOAuthMethod
inputs: Record<string, string>
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(() => (
<OAuthCode
integration={props.integration}
title={props.method.label}
attempt={result.data}
onConnected={props.onConnected}
/>
))
return
}
dialog.replace(() => (
<OAuthAuto
integration={props.integration}
title={props.method.label}
attempt={result.data}
onConnected={props.onConnected}
/>
))
})
.catch((cause) => {
toast.show({ variant: "error", message: message(cause) })
dialog.clear()
})
})
return <OAuthView title={props.method.label} message="Starting authorization..." />
}
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<typeof setTimeout> | 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 (
<OAuthView
title={props.title}
url={props.attempt.url}
instructions={props.attempt.instructions}
message="Waiting for authorization..."
copy
/>
)
}
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<string>()
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 (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={(code) => {
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={() => (
<box gap={1}>
<text fg={themeV2.text.subdued}>{props.attempt.instructions}</text>
<Link href={props.attempt.url} fg={themeV2.markdown.link} />
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error.default}>{value()}</text>}</Show>
</box>
)}
/>
)
}
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
const dialog = useDialog()
const { themeV2 } = useTheme().contextual("elevated")
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
{props.title}
</text>
<text fg={themeV2.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={props.url}>
{(url) => (
<box gap={1}>
<Link href={url()} fg={themeV2.markdown.link} />
<Show when={props.instructions}>
{(instructions) => <text fg={themeV2.text.subdued}>{instructions()}</text>}
</Show>
</box>
)}
</Show>
<text fg={themeV2.text.subdued}>{props.message}</text>
<Show when={props.copy}>
<text fg={themeV2.text.default}>
c <span style={{ fg: themeV2.text.subdued }}>copy</span>
</text>
</Show>
</box>
)
}
async function promptInputs(
dialog: ReturnType<typeof useDialog>,
prompts: NonNullable<IntegrationOAuthMethod["prompts"]>,
) {
const inputs: Record<string, string> = {}
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<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((option) => ({
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<string | null>((resolve) => {
dialog.replace(
() => <DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={resolve} />,
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}
async function connected(
integration: IntegrationInfo,
data: ReturnType<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
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<typeof useData>, 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<typeof useData>,
dialog: ReturnType<typeof useDialog>,
toast: ReturnType<typeof useToast>,
) {
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<typeof useData>) {
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"
}