mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-07 07:48:16 +00:00
Compare commits
3
Commits
v2
...
bedrock-setup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46167b6c4b | ||
|
|
4db1ff7cd0 | ||
|
|
e9e4999a7d |
No files matched your search
@@ -37,7 +37,10 @@ export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Creden
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
|
||||
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
|
||||
const identity = await fromNodeProviderChain({
|
||||
ignoreCache: true,
|
||||
...(options.profile === undefined ? {} : { profile: options.profile }),
|
||||
})()
|
||||
return {
|
||||
region: options.region,
|
||||
accessKeyId: identity.accessKeyId,
|
||||
|
||||
@@ -11,8 +11,7 @@ import { useData } from "@/runtime/server/current"
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" | "form" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
|
||||
@@ -100,11 +99,11 @@ export function createProviderConnectionController(options: {
|
||||
|
||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
(method): method is ProviderConnectMethod =>
|
||||
method.type === "key" || method.type === "oauth" || method.type === "form",
|
||||
)
|
||||
|
||||
if (values?.length) return [...values]
|
||||
|
||||
if (values?.length)
|
||||
return values.toSorted((a, b) => ("order" in a ? (a.order ?? 0) : 0) - ("order" in b ? (b.order ?? 0) : 0))
|
||||
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
|
||||
})
|
||||
|
||||
@@ -357,7 +356,26 @@ export function createProviderConnectionController(options: {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.type === "form") {
|
||||
dispatch({ type: "auth.answer", answer: merged })
|
||||
dispatch({ type: "auth.pending" })
|
||||
const result = await serverSDK.api.integration.connect
|
||||
.form({
|
||||
integrationID: options.provider(),
|
||||
methodID: selected.id,
|
||||
answer: merged,
|
||||
location: location(),
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: errorMessage(result.error) })
|
||||
return
|
||||
}
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
|
||||
if (selected.form?.some((field) => field.type !== "string")) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { List } from "@opencode/ui/list"
|
||||
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { TextField } from "@opencode/ui/text-field"
|
||||
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
|
||||
@@ -132,8 +133,7 @@ export const DialogConnectProvider: Component<{
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
@@ -172,7 +172,8 @@ export const DialogConnectProvider: Component<{
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
|
||||
consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
|
||||
}}
|
||||
>
|
||||
@@ -452,9 +453,7 @@ function ProviderConnection(props: {
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// 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.
|
||||
global.models.show(
|
||||
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
|
||||
|
||||
if (state.catalogPending) {
|
||||
setState("noModels", true)
|
||||
@@ -578,7 +577,9 @@ function ProviderConnection(props: {
|
||||
})
|
||||
createEffect(() => {
|
||||
const current = controller.auth.state()
|
||||
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
|
||||
props.onAuthorization(
|
||||
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
|
||||
)
|
||||
})
|
||||
|
||||
const provider = createMemo(() => ({
|
||||
@@ -592,9 +593,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
|
||||
if (value.type === "key") return language.t("provider.connect.method.apiKey")
|
||||
|
||||
if (value.type === "key") return value.label ?? language.t("provider.connect.method.apiKey")
|
||||
return value.label ?? ""
|
||||
}
|
||||
|
||||
@@ -647,6 +646,8 @@ function ProviderConnection(props: {
|
||||
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
|
||||
) as Record<string, string>,
|
||||
index: 0,
|
||||
customKey: "",
|
||||
customText: "",
|
||||
})
|
||||
|
||||
const fields = createMemo<StringForm[]>(() => {
|
||||
@@ -679,9 +680,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const valid = createMemo(() => {
|
||||
const item = current()
|
||||
|
||||
if (!item || item.field.options) return false
|
||||
|
||||
if (!item || (item.field.options && !item.field.custom)) return false
|
||||
if (!item.field.required) return true
|
||||
|
||||
return (formStore.value[item.field.key] ?? "").trim().length > 0
|
||||
@@ -694,8 +693,7 @@ function ProviderConnection(props: {
|
||||
const next = fields().findIndex((field, i) => i > index && matches(field, value))
|
||||
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
|
||||
setFormStore({ index: next, customKey: "", customText: "" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -705,9 +703,7 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const item = current()
|
||||
|
||||
if (!item || item.field.options) return
|
||||
|
||||
if (!item || (item.field.options && !item.field.custom)) return
|
||||
if (!valid()) return
|
||||
await next(item.index, formStore.value)
|
||||
}
|
||||
@@ -729,13 +725,22 @@ function ProviderConnection(props: {
|
||||
|
||||
return field
|
||||
})
|
||||
const custom = () => {
|
||||
const field = select()
|
||||
if (!field?.custom) return false
|
||||
const value = formStore.value[field.key]
|
||||
return (
|
||||
formStore.customKey === field.key ||
|
||||
(value !== undefined && !field.options?.some((option) => option.value === value))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
|
||||
<Switch>
|
||||
<Match when={item()?.field.options === undefined}>
|
||||
<TextField
|
||||
type="text"
|
||||
type={text()?.format === "password" ? "password" : "text"}
|
||||
label={text()?.title ?? ""}
|
||||
placeholder={text()?.placeholder}
|
||||
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||
@@ -754,36 +759,97 @@ function ProviderConnection(props: {
|
||||
<div class="w-full flex flex-col gap-1.5">
|
||||
<div class="text-14-regular text-text-base">{select()?.title}</div>
|
||||
<div>
|
||||
<List
|
||||
class="px-3"
|
||||
items={select()?.options ?? []}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
const field = select()
|
||||
|
||||
if (!field) return
|
||||
|
||||
const nextValue = {
|
||||
...formStore.value,
|
||||
[field.key]: value.value,
|
||||
}
|
||||
|
||||
setFormStore("value", field.key, value.value)
|
||||
void next(item()!.index, nextValue)
|
||||
}}
|
||||
<Show
|
||||
when={select()?.custom}
|
||||
fallback={
|
||||
<List
|
||||
class="px-3"
|
||||
items={select()?.options ?? []}
|
||||
key={(x) => x.value}
|
||||
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
const field = select()
|
||||
if (!field) return
|
||||
const nextValue = {
|
||||
...formStore.value,
|
||||
[field.key]: value.value,
|
||||
}
|
||||
setFormStore("value", field.key, value.value)
|
||||
void next(item()!.index, nextValue)
|
||||
}}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
<span class="text-14-regular text-text-weak">{option.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center gap-x-2">
|
||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
<span class="text-14-regular text-text-weak">{option.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
<RadioGroup
|
||||
label={select()?.title}
|
||||
hideLabel
|
||||
description={select()?.description}
|
||||
value={
|
||||
custom()
|
||||
? String(select()?.options?.length ?? 0)
|
||||
: String(
|
||||
select()?.options?.findIndex((option) => option.value === formStore.value[select()!.key]) ??
|
||||
-1,
|
||||
)
|
||||
}
|
||||
onChange={(value) => {
|
||||
const field = select()
|
||||
if (!field) return
|
||||
const option = field.options?.[Number(value)]
|
||||
if (option) {
|
||||
if (custom()) setFormStore("customText", formStore.value[field.key] ?? "")
|
||||
setFormStore({ customKey: "" })
|
||||
setFormStore("value", field.key, option.value)
|
||||
return
|
||||
}
|
||||
setFormStore({ customKey: field.key })
|
||||
setFormStore("value", field.key, formStore.customText)
|
||||
}}
|
||||
>
|
||||
<For each={select()?.options}>
|
||||
{(option, index) => (
|
||||
<RadioItem value={String(index())} label={option.label} description={option.description} />
|
||||
)}
|
||||
</For>
|
||||
<RadioItem
|
||||
value={String(select()?.options?.length ?? 0)}
|
||||
label={language.t("ui.messagePart.option.typeOwnAnswer")}
|
||||
/>
|
||||
<Show when={custom()}>
|
||||
<TextField
|
||||
ref={(input: HTMLInputElement) => queueMicrotask(() => input.focus())}
|
||||
type={select()?.format === "password" ? "password" : "text"}
|
||||
label={language.t("ui.messagePart.option.typeOwnAnswer")}
|
||||
hideLabel
|
||||
placeholder={select()?.placeholder ?? language.t("ui.question.custom.placeholder")}
|
||||
value={formStore.value[select()!.key] ?? ""}
|
||||
onChange={(value) => {
|
||||
const field = select()
|
||||
if (!field) return
|
||||
setFormStore({ customText: value })
|
||||
setFormStore("value", field.key, value)
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</RadioGroup>
|
||||
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
@@ -137,12 +137,14 @@ const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField
|
||||
if (value !== custom) return value
|
||||
}
|
||||
const value = yield* prompt<string>(() =>
|
||||
text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
field.type === "string" && field.format === "password"
|
||||
? password({ message, validate: (input) => validateText(field, input) })
|
||||
: text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
)
|
||||
if (!value && !field.required) return undefined
|
||||
if (field.type === "string") return value
|
||||
|
||||
@@ -127,6 +127,13 @@ const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
|
||||
) {
|
||||
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
|
||||
if (method.type === "command") return yield* commandLogin(client, integration, method)
|
||||
if (method.type === "form")
|
||||
return yield* request((signal) =>
|
||||
client.integration.connect.form(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
return yield* oauthLogin(client, integration, method, answer)
|
||||
})
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
("order" in a && a.order !== undefined ? a.order : Number(a.type === "key")) -
|
||||
("order" in b && b.order !== undefined ? b.order : Number(b.type === "key")),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
|
||||
@@ -1597,6 +1597,18 @@ export type IntegrationConnectKeyOperation<E = never> = (
|
||||
input: IntegrationConnectKeyInput,
|
||||
) => Effect.Effect<IntegrationConnectKeyOutput, E>
|
||||
|
||||
export type IntegrationConnectFormInput = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly answer?: Form.Answer | undefined
|
||||
readonly label?: string | undefined
|
||||
}
|
||||
export type IntegrationConnectFormOutput = void
|
||||
export type IntegrationConnectFormOperation<E = never> = (
|
||||
input: IntegrationConnectFormInput,
|
||||
) => Effect.Effect<IntegrationConnectFormOutput, E>
|
||||
|
||||
export type IntegrationOauthConnectInput = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
@@ -1687,7 +1699,10 @@ export interface IntegrationApi<E = never> {
|
||||
readonly list: IntegrationListOperation<E>
|
||||
readonly get: IntegrationGetOperation<E>
|
||||
readonly wellknown: { readonly add: IntegrationWellknownAddOperation<E> }
|
||||
readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
|
||||
readonly connect: {
|
||||
readonly key: IntegrationConnectKeyOperation<E>
|
||||
readonly form: IntegrationConnectFormOperation<E>
|
||||
}
|
||||
readonly oauth: {
|
||||
readonly connect: IntegrationOauthConnectOperation<E>
|
||||
readonly status: IntegrationOauthStatusOperation<E>
|
||||
|
||||
@@ -127,6 +127,8 @@ import type {
|
||||
IntegrationWellknownAddOutput,
|
||||
IntegrationConnectKeyInput,
|
||||
IntegrationConnectKeyOutput,
|
||||
IntegrationConnectFormInput,
|
||||
IntegrationConnectFormOutput,
|
||||
IntegrationOauthConnectInput,
|
||||
IntegrationOauthConnectOutput,
|
||||
IntegrationOauthStatusInput,
|
||||
@@ -897,6 +899,15 @@ const EndpointIntegrationConnectKey = (raw: RawClient["server.integration"]) =>
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointIntegrationConnectForm = (raw: RawClient["server.integration"]) => (input: IntegrationConnectFormInput) =>
|
||||
preserveEffect<IntegrationConnectFormOutput>()(
|
||||
raw["integration.connect.form"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointIntegrationOauthConnect =
|
||||
(raw: RawClient["server.integration"]) => (input: IntegrationOauthConnectInput) =>
|
||||
preserveEffect<IntegrationOauthConnectOutput>()(
|
||||
@@ -965,7 +976,7 @@ const adaptGroupIntegration = (raw: RawClient["server.integration"]) => ({
|
||||
list: EndpointIntegrationList(raw),
|
||||
get: EndpointIntegrationGet(raw),
|
||||
wellknown: { add: EndpointIntegrationWellknownAdd(raw) },
|
||||
connect: { key: EndpointIntegrationConnectKey(raw) },
|
||||
connect: { key: EndpointIntegrationConnectKey(raw), form: EndpointIntegrationConnectForm(raw) },
|
||||
oauth: {
|
||||
connect: EndpointIntegrationOauthConnect(raw),
|
||||
status: EndpointIntegrationOauthStatus(raw),
|
||||
|
||||
@@ -121,6 +121,8 @@ import type {
|
||||
IntegrationWellknownAddOutput,
|
||||
IntegrationConnectKeyInput,
|
||||
IntegrationConnectKeyOutput,
|
||||
IntegrationConnectFormInput,
|
||||
IntegrationConnectFormOutput,
|
||||
IntegrationOauthConnectInput,
|
||||
IntegrationOauthConnectOutput,
|
||||
IntegrationOauthStatusInput,
|
||||
@@ -1228,6 +1230,19 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
form: (input: IntegrationConnectFormInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationConnectFormOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/form`,
|
||||
query: { location: input["location"] },
|
||||
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
oauth: {
|
||||
connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -1445,7 +1445,7 @@ export type FormStringField = {
|
||||
hidden?: boolean
|
||||
when?: Array<FormWhen>
|
||||
type: "string"
|
||||
format?: "email" | "uri" | "date" | "date-time"
|
||||
format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
pattern?: string
|
||||
@@ -1617,7 +1617,7 @@ export type FormStringField1 = {
|
||||
hidden?: boolean
|
||||
when?: Array<FormWhen1>
|
||||
type: "string"
|
||||
format?: "email" | "uri" | "date" | "date-time"
|
||||
format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
pattern?: string
|
||||
@@ -2312,7 +2312,9 @@ export type FormDetail = {
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string; order?: number; form?: FormFields }
|
||||
|
||||
export type IntegrationFormMethod = { id: string; type: "form"; label: string; order?: number; form?: FormFields }
|
||||
|
||||
export type CredentialEntry = {
|
||||
id: string
|
||||
@@ -2352,6 +2354,7 @@ export type IntegrationMethod =
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
| IntegrationFormMethod
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
@@ -4665,7 +4668,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -4767,7 +4770,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -4876,7 +4879,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -4978,7 +4981,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -5087,7 +5090,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -5189,7 +5192,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -5298,7 +5301,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -5400,7 +5403,7 @@ export type SessionFormCreateInput = {
|
||||
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
|
||||
}>
|
||||
readonly type: "string"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time"
|
||||
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
@@ -5688,6 +5691,28 @@ export type IntegrationConnectKeyInput = {
|
||||
|
||||
export type IntegrationConnectKeyOutput = void
|
||||
|
||||
export type IntegrationConnectFormInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
readonly methodID: {
|
||||
readonly methodID: string
|
||||
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["methodID"]
|
||||
readonly answer?: {
|
||||
readonly methodID: string
|
||||
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["answer"]
|
||||
readonly label?: {
|
||||
readonly methodID: string
|
||||
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
|
||||
readonly label?: string | undefined
|
||||
}["label"]
|
||||
}
|
||||
|
||||
export type IntegrationConnectFormOutput = void
|
||||
|
||||
export type IntegrationOauthConnectInput = {
|
||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
|
||||
@@ -45,6 +45,9 @@ export type CommandMethod = Integration.CommandMethod
|
||||
export const KeyMethod = Integration.KeyMethod
|
||||
export type KeyMethod = Integration.KeyMethod
|
||||
|
||||
export const FormMethod = Integration.FormMethod
|
||||
export type FormMethod = Integration.FormMethod
|
||||
|
||||
export const EnvMethod = Integration.EnvMethod
|
||||
export type EnvMethod = Integration.EnvMethod
|
||||
|
||||
@@ -82,6 +85,12 @@ export interface KeyImplementation {
|
||||
readonly method: KeyMethod
|
||||
}
|
||||
|
||||
export interface FormImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly method: FormMethod
|
||||
readonly connect: (answer: Form.Answer) => Effect.Effect<Credential.Key | Credential.External, unknown>
|
||||
}
|
||||
|
||||
export interface CommandImplementation {
|
||||
readonly integrationID: ID
|
||||
readonly method: CommandMethod
|
||||
@@ -92,7 +101,12 @@ export interface EnvImplementation {
|
||||
readonly method: EnvMethod
|
||||
}
|
||||
|
||||
export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation
|
||||
export type Implementation =
|
||||
| OAuthImplementation
|
||||
| CommandImplementation
|
||||
| KeyImplementation
|
||||
| EnvImplementation
|
||||
| FormImplementation
|
||||
|
||||
export const Attempt = Integration.Attempt
|
||||
export type Attempt = Integration.Attempt
|
||||
@@ -136,6 +150,7 @@ type Entry = {
|
||||
ref: Types.DeepMutable<Ref>
|
||||
methods: Types.DeepMutable<Method>[]
|
||||
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
|
||||
forms: Map<MethodID, Types.DeepMutable<FormImplementation>>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -180,6 +195,13 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
/** Validates a setup form and stores the credential returned by its implementation. */
|
||||
readonly form: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly methodID: MethodID
|
||||
readonly answer?: Form.Answer
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
/** Selects a stored credential as the active integration connection. */
|
||||
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
@@ -301,6 +323,7 @@ const layer = Layer.effect(
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
forms: new Map(),
|
||||
}
|
||||
if (!editor.integrations.has(id)) editor.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
@@ -317,6 +340,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
forms: new Map<MethodID, Types.DeepMutable<FormImplementation>>(),
|
||||
}
|
||||
if (!editor.integrations.has(implementation.integrationID)) {
|
||||
editor.integrations.set(implementation.integrationID, current)
|
||||
@@ -327,6 +351,8 @@ const layer = Layer.effect(
|
||||
return method.id === implementation.method.id
|
||||
if (method.type === "command" && implementation.method.type === "command")
|
||||
return method.id === implementation.method.id
|
||||
if (method.type === "form" && implementation.method.type === "form")
|
||||
return method.id === implementation.method.id
|
||||
return true
|
||||
})
|
||||
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
||||
@@ -337,6 +363,8 @@ const layer = Layer.effect(
|
||||
implementation as Types.DeepMutable<OAuthImplementation>,
|
||||
)
|
||||
}
|
||||
if ("connect" in implementation)
|
||||
current.forms.set(implementation.method.id, implementation as Types.DeepMutable<FormImplementation>)
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
const current = editor.integrations.get(integrationID)
|
||||
@@ -345,10 +373,12 @@ const layer = Layer.effect(
|
||||
if (candidate.type !== method.type) return false
|
||||
if (candidate.type === "oauth" && method.type === "oauth") return candidate.id === method.id
|
||||
if (candidate.type === "command" && method.type === "command") return candidate.id === method.id
|
||||
if (candidate.type === "form" && method.type === "form") return candidate.id === method.id
|
||||
return true
|
||||
})
|
||||
if (index !== -1) current.methods.splice(index, 1)
|
||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||
if (method.type === "form") current.forms.delete(method.id)
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -737,6 +767,25 @@ const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
activate: Effect.fn("Integration.connection.activate")((credentialID) => credentials.activate(credentialID)),
|
||||
form: Effect.fn("Integration.connection.form")(function* (input) {
|
||||
const implementation = state.get().integrations.get(input.integrationID)?.forms.get(input.methodID)
|
||||
if (!implementation) return yield* new AuthorizationError({ cause: new Error("Connection method not found") })
|
||||
const answer = input.answer ?? {}
|
||||
if (implementation.method.form) {
|
||||
const invalid =
|
||||
Form.validateFields(implementation.method.form) ?? Form.validateAnswer(implementation.method.form, answer)
|
||||
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
|
||||
}
|
||||
if (!implementation.method.form && Object.keys(answer).length > 0)
|
||||
return yield* new AuthorizationError({
|
||||
cause: new Error("Connection method does not accept a form answer"),
|
||||
})
|
||||
yield* createCredential({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: yield* authorize(implementation.connect(answer)),
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
|
||||
credentials.update(credentialID, updates),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Auth } from "@opencode/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { BedrockAuth } from "@opencode/ai/protocols/utils/bedrock-auth"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
|
||||
import type { RuntimeInfo } from "./model.js"
|
||||
@@ -199,7 +200,13 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
const configuration =
|
||||
credential?.type === "key"
|
||||
? credential.configuration?.auth === "sigv4" &&
|
||||
resolved.package?.startsWith("@opencode/ai/providers/amazon-bedrock")
|
||||
? Struct.omit(credential.configuration, ["accessKeyId", "sessionToken"])
|
||||
: credential.configuration
|
||||
: undefined
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
const loadAISDK = dependencies?.loadAISDK
|
||||
@@ -207,7 +214,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential, configured),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
@@ -225,7 +232,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const settings = {
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
...nativeCredentialSettings(specifier, credential, configured),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
}
|
||||
@@ -305,8 +312,33 @@ function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential || credential.type === "external") return {}
|
||||
const nativeCredentialSettings = (
|
||||
specifier: string,
|
||||
credential: Credential.Value | undefined,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "external") {
|
||||
if (specifier.startsWith("@opencode/ai/providers/amazon-bedrock") && credential.methodID === "aws-credentials")
|
||||
return { auth: "sigv4", profile: credential.metadata?.profile, credentials: undefined }
|
||||
return {}
|
||||
}
|
||||
if (
|
||||
credential.type === "key" &&
|
||||
specifier.startsWith("@opencode/ai/providers/amazon-bedrock") &&
|
||||
credential.configuration?.auth === "sigv4"
|
||||
)
|
||||
return {
|
||||
auth: "sigv4",
|
||||
credentials: {
|
||||
region: BedrockAuth.resolveRegion({
|
||||
region: typeof settings.region === "string" ? settings.region : undefined,
|
||||
}),
|
||||
accessKeyId: credential.configuration.accessKeyId,
|
||||
secretAccessKey: credential.key,
|
||||
...(credential.configuration.sessionToken ? { sessionToken: credential.configuration.sessionToken } : {}),
|
||||
},
|
||||
}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (specifier === "@opencode/ai/providers/anthropic" || specifier === "@opencode/ai/providers/anthropic-compatible")
|
||||
return { authToken: credential.access }
|
||||
|
||||
@@ -279,6 +279,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
return yield* response(Effect.succeed(item))
|
||||
}),
|
||||
connect: {
|
||||
form: (input) =>
|
||||
integration.connection.form({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
answer: input.answer,
|
||||
label: input.label,
|
||||
}),
|
||||
key: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
@@ -629,6 +636,12 @@ export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["sto
|
||||
}
|
||||
|
||||
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
|
||||
if ("connect" in input)
|
||||
return {
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
connect: input.connect,
|
||||
}
|
||||
if ("authorize" in input) {
|
||||
const refresh = input.refresh
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
// Ambient inputs the AWS default credential chain can turn into credentials
|
||||
@@ -19,6 +24,35 @@ const isBedrock = (item: { readonly package: string }) =>
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const paths = [
|
||||
process.env.AWS_CONFIG_FILE ?? path.join(Global.Path.home, ".aws", "config"),
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE ?? path.join(Global.Path.home, ".aws", "credentials"),
|
||||
]
|
||||
const files = yield* Effect.all(
|
||||
paths.map((file) => fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))),
|
||||
)
|
||||
// Discover names only. Resolving every profile here could run credential helpers or contact AWS.
|
||||
const profiles = Array.from(
|
||||
new Set(
|
||||
files.flatMap((content, index) =>
|
||||
Array.from((content ?? "").matchAll(/^\s*\[([^\]\r\n]+)\]/gm)).flatMap((match) => {
|
||||
const section = match[1].trim()
|
||||
if (index === 1) return [section]
|
||||
if (section === "default") return [section]
|
||||
return section.startsWith("profile ") ? [section.slice(8).trim()] : []
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.toSorted()
|
||||
const sources = paths
|
||||
.filter((_, index) => profiles.length === 0 || files[index] !== undefined)
|
||||
.map((file) =>
|
||||
file.startsWith(Global.Path.home + path.sep) ? `~${file.slice(Global.Path.home.length)}` : file,
|
||||
)
|
||||
.join(" and ")
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
// models.dev advertises AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
|
||||
// AWS_REGION alongside the bearer token. Only the bearer token is a key;
|
||||
@@ -27,6 +61,76 @@ export const AmazonBedrockPlugin = define({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: { type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
})
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: { type: "key", label: "Bedrock API key", order: -3 },
|
||||
})
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: {
|
||||
id: "aws-credentials",
|
||||
type: "form",
|
||||
label: "AWS profile (SSO or named profile)",
|
||||
order: -2,
|
||||
form: [
|
||||
{
|
||||
key: "profile",
|
||||
type: "string",
|
||||
title: "AWS profile",
|
||||
description: profiles.length
|
||||
? `Found ${profiles.length} profile${profiles.length === 1 ? "" : "s"} in ${sources} on the server.`
|
||||
: `No AWS profiles found in ${sources} on the server.`,
|
||||
required: true,
|
||||
minLength: 1,
|
||||
pattern: "\\S",
|
||||
placeholder: "Profile name",
|
||||
custom: true,
|
||||
options: profiles.map((profile) => ({ value: profile, label: profile })),
|
||||
},
|
||||
],
|
||||
},
|
||||
connect: (answer) =>
|
||||
Effect.succeed(
|
||||
Credential.External.make({
|
||||
type: "external",
|
||||
methodID: Integration.MethodID.make("aws-credentials"),
|
||||
metadata: { auth: "sigv4", profile: String(answer.profile).trim() },
|
||||
}),
|
||||
),
|
||||
})
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.amazonBedrock,
|
||||
method: {
|
||||
id: "aws-access-keys",
|
||||
type: "form",
|
||||
label: "AWS access key + secret",
|
||||
order: -1,
|
||||
form: [
|
||||
{ key: "accessKeyId", type: "string", title: "Access key ID", required: true },
|
||||
{ key: "secretAccessKey", type: "string", format: "password", title: "Secret access key", required: true },
|
||||
{
|
||||
key: "sessionToken",
|
||||
type: "string",
|
||||
format: "password",
|
||||
title: "Session token (optional)",
|
||||
description:
|
||||
"Required for temporary credentials. These pasted credentials cannot renew themselves when they expire.",
|
||||
},
|
||||
],
|
||||
},
|
||||
connect: (answer) =>
|
||||
Effect.succeed(
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key: String(answer.secretAccessKey).trim(),
|
||||
configuration: {
|
||||
auth: "sigv4",
|
||||
accessKeyId: String(answer.accessKeyId).trim(),
|
||||
...(answer.sessionToken ? { sessionToken: String(answer.sessionToken).trim() } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
for (const item of evt.list()) {
|
||||
|
||||
@@ -32,6 +32,7 @@ const integrations = Layer.mock(Integration.Service, {
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
form: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
|
||||
@@ -330,6 +330,7 @@ function resourceMcpLayer(
|
||||
active: unusedIntegration,
|
||||
resolve: unusedIntegration,
|
||||
key: unusedIntegration,
|
||||
form: unusedIntegration,
|
||||
activate: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
|
||||
@@ -372,6 +372,7 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
form: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
|
||||
@@ -78,6 +78,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connect: {
|
||||
key: () => Effect.die("unused integration.connect.key"),
|
||||
form: () => Effect.die("unused integration.connect.form"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused integration.oauth.connect"),
|
||||
@@ -297,6 +298,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connect: {
|
||||
key: () => Effect.die("unused integration.connect.key"),
|
||||
form: () => Effect.die("unused integration.connect.form"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused integration.oauth.connect"),
|
||||
@@ -339,6 +341,14 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
method: {
|
||||
list: (id) => editor.method.list(Integration.ID.make(id)),
|
||||
update: (input) => {
|
||||
if ("connect" in input) {
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
|
||||
connect: input.connect,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ("authorize" in input) {
|
||||
const methodID = Integration.MethodID.make(input.method.id)
|
||||
const refresh = input.refresh
|
||||
@@ -459,7 +469,7 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
|
||||
}
|
||||
|
||||
function internalMethod(value: IntegrationMethod): Integration.Method {
|
||||
if (value.type === "oauth" || value.type === "command") {
|
||||
if (value.type === "oauth" || value.type === "command" || value.type === "form") {
|
||||
return { ...value, id: Integration.MethodID.make(value.id) }
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -104,8 +104,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(integrationID))?.methods).toEqual([
|
||||
{ type: "key" },
|
||||
expect((yield* integrations.get(integrationID))?.methods.filter((method) => method.type === "env")).toEqual([
|
||||
{ type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -25,6 +25,15 @@ export interface IntegrationCommandMethod {
|
||||
export interface IntegrationKeyMethod {
|
||||
readonly type: "key"
|
||||
readonly label?: string
|
||||
readonly order?: number
|
||||
readonly form?: Form.Fields
|
||||
}
|
||||
|
||||
export interface IntegrationFormMethod {
|
||||
readonly id: string
|
||||
readonly type: "form"
|
||||
readonly label: string
|
||||
readonly order?: number
|
||||
readonly form?: Form.Fields
|
||||
}
|
||||
|
||||
@@ -38,6 +47,7 @@ export type IntegrationMethod =
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
| IntegrationFormMethod
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
@@ -62,6 +72,11 @@ export type IntegrationOAuthMethodRegistration = {
|
||||
}
|
||||
export type IntegrationMethodRegistration =
|
||||
| IntegrationOAuthMethodRegistration
|
||||
| {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationFormMethod
|
||||
readonly connect: (answer: Form.Answer) => Effect.Effect<Credential.Key | Credential.External, unknown>
|
||||
}
|
||||
| {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationCommandMethod
|
||||
|
||||
@@ -355,6 +355,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
connect: {
|
||||
form: adaptApiMethod(IntegrationEndpoints["integration.connect.form"], host.integration.connect.form),
|
||||
key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
|
||||
},
|
||||
oauth: {
|
||||
@@ -394,6 +395,11 @@ export function fromPromise(plugin: Plugin) {
|
||||
method: {
|
||||
list: editor.method.list,
|
||||
update: (input) => {
|
||||
if ("connect" in input)
|
||||
return editor.method.update({
|
||||
...input,
|
||||
connect: (answer) => Effect.tryPromise(() => input.connect(answer)),
|
||||
})
|
||||
if (!("authorize" in input)) return editor.method.update(input)
|
||||
const refresh = input.refresh
|
||||
editor.method.update({
|
||||
|
||||
@@ -24,6 +24,15 @@ export interface IntegrationCommandMethod {
|
||||
export interface IntegrationKeyMethod {
|
||||
readonly type: "key"
|
||||
readonly label?: string
|
||||
readonly order?: number
|
||||
readonly form?: Form.Fields
|
||||
}
|
||||
|
||||
export interface IntegrationFormMethod {
|
||||
readonly id: string
|
||||
readonly type: "form"
|
||||
readonly label: string
|
||||
readonly order?: number
|
||||
readonly form?: Form.Fields
|
||||
}
|
||||
|
||||
@@ -37,6 +46,7 @@ export type IntegrationMethod =
|
||||
| IntegrationCommandMethod
|
||||
| IntegrationKeyMethod
|
||||
| IntegrationEnvMethod
|
||||
| IntegrationFormMethod
|
||||
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
@@ -63,6 +73,11 @@ export type IntegrationOAuthMethodRegistration = {
|
||||
|
||||
export type IntegrationMethodRegistration =
|
||||
| IntegrationOAuthMethodRegistration
|
||||
| {
|
||||
readonly integrationID: string
|
||||
readonly method: IntegrationFormMethod
|
||||
readonly connect: (answer: Form.Answer) => Promise<Credential.Key | Credential.External>
|
||||
}
|
||||
| { readonly integrationID: string; readonly method: IntegrationCommandMethod }
|
||||
| {
|
||||
readonly integrationID: string
|
||||
|
||||
@@ -79,6 +79,27 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integration.connect.form", "/api/integration/:integrationID/connect/form", {
|
||||
params: { integrationID: Integration.ID },
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({
|
||||
methodID: Integration.MethodID,
|
||||
answer: Schema.optional(Form.Answer),
|
||||
label: Schema.optional(Schema.String),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [IntegrationNotFoundError, InvalidRequestError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "integration.connect.form",
|
||||
summary: "Connect with setup form",
|
||||
description: "Run a setup form method and store its key or external credential-source reference.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("integration.oauth.connect", "/api/integration/:integrationID/connect/oauth", {
|
||||
params: { integrationID: Integration.ID },
|
||||
|
||||
@@ -50,7 +50,7 @@ const FieldBase = {
|
||||
export const StringField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("string"),
|
||||
format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
|
||||
format: Schema.Literals(["email", "uri", "date", "date-time", "password"]).pipe(optional),
|
||||
minLength: NonNegativeInt.pipe(optional),
|
||||
maxLength: NonNegativeInt.pipe(optional),
|
||||
pattern: Schema.String.pipe(optional),
|
||||
|
||||
@@ -35,16 +35,27 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: optional(Schema.String),
|
||||
order: optional(Schema.Finite),
|
||||
form: optional(Form.Fields),
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
|
||||
/** A setup form that saves a credential without an OAuth or command attempt. */
|
||||
export interface FormMethod extends Schema.Schema.Type<typeof FormMethod> {}
|
||||
export const FormMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("form"),
|
||||
label: Schema.String,
|
||||
order: optional(Schema.Finite),
|
||||
form: optional(Form.Fields),
|
||||
}).annotate({ identifier: "Integration.FormMethod" })
|
||||
|
||||
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod])
|
||||
export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod, FormMethod])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Integration.Method" })
|
||||
export type Method = typeof Method.Type
|
||||
|
||||
@@ -84,6 +84,26 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"integration.connect.form",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* Integration.Service
|
||||
if (!(yield* service.get(ctx.params.integrationID)))
|
||||
return yield* new IntegrationNotFoundError({
|
||||
integrationID: ctx.params.integrationID,
|
||||
message: `Integration not found: ${ctx.params.integrationID}`,
|
||||
})
|
||||
yield* authorize(
|
||||
service.connection.form({
|
||||
integrationID: ctx.params.integrationID,
|
||||
methodID: ctx.payload.methodID,
|
||||
answer: ctx.payload.answer,
|
||||
label: ctx.payload.label,
|
||||
}),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"integration.oauth.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { ScrollBoxRenderable, TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type {
|
||||
ConnectionInfo,
|
||||
IntegrationCommandConnectOutput,
|
||||
@@ -12,7 +13,8 @@ import type {
|
||||
LocationRef,
|
||||
} from "@opencode/client"
|
||||
import { openUrl } from "@opencode/util/open"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -24,8 +26,17 @@ import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { Link } from "../ui/link"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
|
||||
import {
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
type FormAnswerField,
|
||||
} from "../util/form"
|
||||
|
||||
const INTEGRATION_PRIORITY: Record<string, number> = {
|
||||
"opencode-go": 0,
|
||||
@@ -40,6 +51,11 @@ type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }
|
||||
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
|
||||
type CommandAttempt = IntegrationCommandConnectOutput["data"]
|
||||
type OnIntegrationConnected = (providerID?: string) => void
|
||||
type ConnectionContext = {
|
||||
client: ReturnType<typeof useClient>
|
||||
data: ReturnType<typeof useData>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}
|
||||
const CANCELLED = Symbol("cancelled")
|
||||
const CUSTOM = Symbol("custom")
|
||||
const OPEN = Symbol("open")
|
||||
@@ -58,7 +74,11 @@ export function integrationOptions(list: IntegrationInfo[]) {
|
||||
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"))
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
("order" in a && a.order !== undefined ? a.order : Number(a.type === "key")) -
|
||||
("order" in b && b.order !== undefined ? b.order : Number(b.type === "key")),
|
||||
)
|
||||
}
|
||||
|
||||
export function credentialConnections(integration: IntegrationInfo) {
|
||||
@@ -77,6 +97,7 @@ export function DialogIntegration(
|
||||
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
|
||||
) {
|
||||
const data = useData()
|
||||
const context = { client: useClient(), data, toast: useToast() }
|
||||
const currentLocation = useLocation()
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme().surface("dialog")
|
||||
@@ -96,7 +117,7 @@ export function DialogIntegration(
|
||||
manageConnections(integration, methods, location, dialog, props.onConnected)
|
||||
return
|
||||
}
|
||||
selectMethod(integration, methods, location, dialog, props.onConnected)
|
||||
selectMethod(integration, methods, location, dialog, context, props.onConnected)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -121,7 +142,7 @@ export function DialogIntegration(
|
||||
: undefined,
|
||||
onSelect: () => {
|
||||
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
|
||||
return selectMethod(integration, methods, location, dialog, props.onConnected)
|
||||
return selectMethod(integration, methods, location, dialog, context, props.onConnected)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -156,6 +177,7 @@ function manageConnections(
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const context = { client, data, toast }
|
||||
const theme = useTheme().surface("dialog")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
@@ -180,7 +202,8 @@ function manageConnections(
|
||||
{
|
||||
title: "Add account",
|
||||
value: "add",
|
||||
onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
|
||||
onSelect: () =>
|
||||
selectMethod(current() ?? integration, methods, location, dialog, context, onConnected),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -201,7 +224,7 @@ function manageConnections(
|
||||
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)
|
||||
return selectMethod(current() ?? integration, methods, location, dialog, context, onConnected)
|
||||
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
|
||||
void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error)
|
||||
},
|
||||
@@ -267,16 +290,17 @@ function selectMethod(
|
||||
methods: ConnectMethod[],
|
||||
location: LocationRef,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
context: ConnectionContext,
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected)
|
||||
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, context, 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, location, dialog, onConnected),
|
||||
onSelect: () => openMethod(integration, method, location, dialog, context, onConnected),
|
||||
}))}
|
||||
/>
|
||||
))
|
||||
@@ -287,6 +311,7 @@ function openMethod(
|
||||
method: ConnectMethod,
|
||||
location: LocationRef,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
context: ConnectionContext,
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
if (method.type === "key") {
|
||||
@@ -299,9 +324,37 @@ function openMethod(
|
||||
))
|
||||
return
|
||||
}
|
||||
if (method.type === "form") {
|
||||
void beginForm(integration, method, location, dialog, context, onConnected)
|
||||
return
|
||||
}
|
||||
void beginOAuth(integration, method, location, dialog, onConnected)
|
||||
}
|
||||
|
||||
async function beginForm(
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "form" }>,
|
||||
location: LocationRef,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
context: ConnectionContext,
|
||||
onConnected?: OnIntegrationConnected,
|
||||
) {
|
||||
const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
|
||||
if (answer === null) return
|
||||
await context.client.api.integration.connect
|
||||
.form({
|
||||
integrationID: integration.id,
|
||||
methodID: method.id,
|
||||
location: locationQuery(location),
|
||||
answer,
|
||||
})
|
||||
.then(() => connected(integration, location, context.data, dialog, context.toast, onConnected))
|
||||
.catch((cause) => {
|
||||
context.toast.show({ variant: "error", message: errorMessage(cause) })
|
||||
dialog.clear()
|
||||
})
|
||||
}
|
||||
|
||||
async function beginKey(
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
@@ -813,6 +866,13 @@ async function selectAnswer(
|
||||
title: string,
|
||||
field: Extract<FormAnswerField, { type: "boolean" | "string" }>,
|
||||
): Promise<FormValue | undefined | typeof CANCELLED> {
|
||||
if (field.type === "string" && field.custom)
|
||||
return new Promise((resolve) => {
|
||||
dialog.replace(
|
||||
() => <StringChoiceField field={field} onSubmit={resolve} onCancel={() => resolve(CANCELLED)} />,
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
const options =
|
||||
field.type === "boolean"
|
||||
? field.default === false
|
||||
@@ -829,18 +889,12 @@ async function selectAnswer(
|
||||
value: option.value as FormValue,
|
||||
description: option.description,
|
||||
}))
|
||||
const choice = await new Promise<FormValue | typeof CUSTOM | undefined | typeof CANCELLED>((resolve) => {
|
||||
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect<FormValue | typeof CUSTOM | undefined>
|
||||
<DialogSelect<FormValue | undefined>
|
||||
title={formLabel(field) || title}
|
||||
options={[
|
||||
...options,
|
||||
...(field.type === "string" && field.custom
|
||||
? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }]
|
||||
: []),
|
||||
...(!field.required ? [{ title: "Skip", value: undefined }] : []),
|
||||
]}
|
||||
options={[...options, ...(!field.required ? [{ title: "Skip", value: undefined }] : [])]}
|
||||
current={field.type === "string" ? field.default : undefined}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
@@ -848,11 +902,208 @@ async function selectAnswer(
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
if (choice === CUSTOM) {
|
||||
if (field.type !== "string") return CANCELLED
|
||||
return textAnswer(dialog, title, field, "")
|
||||
}
|
||||
|
||||
function StringChoiceField(props: {
|
||||
field: Extract<FormAnswerField, { type: "string" }>
|
||||
onSubmit: (value: string | undefined) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
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,
|
||||
submitted: false,
|
||||
text: formInitialValues([props.field]).custom[props.field.key] ?? "",
|
||||
error: "",
|
||||
})
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
createEffect(() => {
|
||||
const input = inputTarget()
|
||||
if (!input || !store.submitted) return
|
||||
input.traits = { suspend: true, status: "BUSY" }
|
||||
input.blur()
|
||||
})
|
||||
createEffect(() => {
|
||||
const selected = store.selected
|
||||
const row = scroll?.getChildren()[selected]
|
||||
if (row) scroll?.scrollChildIntoView(row.id)
|
||||
})
|
||||
const submit = (value: string | undefined) => {
|
||||
if (store.submitted) return
|
||||
const invalid = formValidateValue(props.field, value)
|
||||
if (invalid) return setStore("error", invalid)
|
||||
setStore("submitted", true)
|
||||
props.onSubmit(value)
|
||||
}
|
||||
return choice
|
||||
const select = (index: number) => {
|
||||
if (store.submitted) return
|
||||
setStore({ selected: index, error: "" })
|
||||
if (!props.field.required && index === rows.length + 1) {
|
||||
submit(undefined)
|
||||
return
|
||||
}
|
||||
if (index === rows.length) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
submit(String(rows[index].value))
|
||||
}
|
||||
const cancel = () => {
|
||||
if (store.editing) {
|
||||
setStore({ editing: false, text: textarea?.plainText ?? store.text, error: "" })
|
||||
return
|
||||
}
|
||||
props.onCancel()
|
||||
}
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
enabled: !store.editing && !store.submitted,
|
||||
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) },
|
||||
{ bind: "escape", title: "Cancel form", group: "Form", run: cancel },
|
||||
],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
priority: 1,
|
||||
target: inputTarget,
|
||||
enabled: store.editing && !store.submitted,
|
||||
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: cancel },
|
||||
],
|
||||
}))
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
|
||||
{formLabel(props.field)}
|
||||
</text>
|
||||
<text fg={theme.text.muted} onMouseUp={cancel}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.field.description}>{(description) => <text fg={theme.text.muted}>{description()}</text>}</Show>
|
||||
<scrollbox
|
||||
gap={1}
|
||||
height={Math.min(
|
||||
count + rows.filter((row) => row.description).length,
|
||||
Math.max(3, Math.floor(dimensions().height / 2) - 6),
|
||||
)}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(value: ScrollBoxRenderable) => {
|
||||
scroll = value
|
||||
}}
|
||||
>
|
||||
<For each={rows}>
|
||||
{(row, index) => (
|
||||
<box onMouseUp={() => select(index())}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={store.selected === index() ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
{index() + 1}.
|
||||
</text>
|
||||
<text fg={store.selected === index() ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
{row.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={row.description}>
|
||||
{(description) => (
|
||||
<text paddingLeft={3} fg={theme.text.muted}>
|
||||
{description()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<box flexDirection="row" gap={1} onMouseUp={() => select(rows.length)}>
|
||||
<text fg={store.selected === rows.length ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
{rows.length + 1}.
|
||||
</text>
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<text fg={store.selected === rows.length ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
{store.text || "Type your own answer"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
height={1}
|
||||
flexGrow={1}
|
||||
wrapMode="none"
|
||||
initialValue={store.text}
|
||||
placeholder={props.field.placeholder ?? "Type your own answer"}
|
||||
placeholderColor={theme.text.muted}
|
||||
textColor={theme.text.formfield.focused}
|
||||
focusedTextColor={theme.text.formfield.focused}
|
||||
cursorColor={theme.text.formfield.focused}
|
||||
ref={(value: TextareaRenderable) => {
|
||||
textarea = value
|
||||
setInputTarget(value)
|
||||
value.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
if (value.isDestroyed) return
|
||||
value.focus()
|
||||
value.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
onContentChange={() => {
|
||||
if (!textarea) return
|
||||
const text = textarea.plainText
|
||||
setStore("text", text)
|
||||
if (store.error && !formValidateValue(props.field, text.trim() || undefined)) setStore("error", "")
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={!props.field.required}>
|
||||
<box flexDirection="row" gap={1} onMouseUp={() => select(rows.length + 1)}>
|
||||
<text fg={store.selected === rows.length + 1 ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
{rows.length + 2}.
|
||||
</text>
|
||||
<text fg={store.selected === rows.length + 1 ? theme.text.formfield.focused : theme.text.formfield.base}>
|
||||
Skip
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<Show when={store.error}>{(error) => <text fg={theme.text.feedback.error.base}>{error()}</text>}</Show>
|
||||
<Show
|
||||
when={store.submitted}
|
||||
fallback={
|
||||
<text fg={theme.text.muted}>
|
||||
{store.editing ? "enter submit · esc back" : "↑/↓ select · enter confirm · esc cancel"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.text.muted}>Connecting…</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function textAnswer(
|
||||
@@ -866,11 +1117,14 @@ function textAnswer(
|
||||
() => {
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={formLabel(field) || title}
|
||||
placeholder={field.type === "string" ? field.placeholder : undefined}
|
||||
secret={field.type === "string" && field.format === "password"}
|
||||
value={initial}
|
||||
busy={busy()}
|
||||
onConfirm={(input) => {
|
||||
const text = input.trim()
|
||||
const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text)
|
||||
@@ -879,6 +1133,7 @@ function textAnswer(
|
||||
setError(invalid)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
resolve(value)
|
||||
}}
|
||||
description={() => (
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DialogPromptProps = {
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
secret?: boolean
|
||||
busy?: boolean
|
||||
busyText?: string
|
||||
onConfirm?: (value: string) => void
|
||||
@@ -113,6 +114,8 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
<box gap={1}>
|
||||
{props.description?.()}
|
||||
<textarea
|
||||
attributes={props.secret ? TextAttributes.HIDDEN : TextAttributes.NONE}
|
||||
selectable={!props.secret}
|
||||
height={1}
|
||||
wrapMode="none"
|
||||
ref={(val: TextareaRenderable) => {
|
||||
|
||||
Reference in new issue
Block a user