diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index bb3fd252b6..f9fc32ec53 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -191,6 +191,7 @@ export const GithubCopilotPlugin = define({ }) yield* ctx.integration.transform((draft) => { + draft.method.remove("github-copilot", { type: "key" }) draft.method.update(oauth(ctx.app)) }) yield* ctx.catalog.transform((evt) => { diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 189eef99b1..d29101bafa 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -62,6 +62,32 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("removes the generic key method", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* integrations.transform((draft) => { + draft.method.update({ + integrationID: Integration.ID.make("github-copilot"), + method: { type: "key" }, + }) + draft.method.update({ + integrationID: Integration.ID.make("github-copilot"), + method: { type: "env", names: ["GITHUB_TOKEN"] }, + }) + }) + yield* addPlugin() + expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toEqual([ + { type: "env", names: ["GITHUB_TOKEN"] }, + { + id: Integration.MethodID.make("device"), + type: "oauth", + label: "Login with GitHub Copilot", + form: expect.any(Array), + }, + ]) + }), + ) + it.live("adds Copilot authentication and request metadata headers", () => Effect.gen(function* () { const requests: Headers[] = [] diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index ae0b89a02d..30c81e50ce 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -6,7 +6,9 @@ import type { IntegrationOauthConnectOutput, IntegrationOAuthMethod, FormAnswer, + FormField, FormFields, + FormValue, } from "@opencode-ai/client" import open from "open" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -20,7 +22,7 @@ import { DialogPrompt } from "../ui/dialog-prompt" import { DialogSelect } from "../ui/dialog-select" import { Link } from "../ui/link" import { useToast } from "../ui/toast" -import { FormInput } from "../routes/session/form" +import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form" const INTEGRATION_PRIORITY: Record = { opencode: 0, @@ -35,6 +37,10 @@ type ConnectMethod = Exclude void +const CANCELLED = Symbol("cancelled") +const CUSTOM = Symbol("custom") +const OPEN = Symbol("open") +const SUBMIT = Symbol("submit") export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -642,24 +648,235 @@ function OAuthView(props: { } async function formAnswer(dialog: ReturnType, title: string, fields: FormFields) { - return new Promise((resolve) => { + const answer: FormAnswer = {} + for (const field of fields) { + if (!active(field, answer)) continue + const value = 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 { + 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, + })) + const choice = await new Promise((resolve) => { dialog.replace( () => ( - { - dialog.clear() - resolve(null) + + 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 }] : []), + ]} + current={field.type === "string" ? field.default : undefined} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), + ) + }) + if (choice === CUSTOM) { + if (field.type !== "string") return CANCELLED + return textAnswer(dialog, title, field, "") + } + return choice +} + +function textAnswer( + dialog: ReturnType, + title: string, + field: Extract, + initial = field.default === undefined ? undefined : String(field.default), +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => { + const theme = useTheme("elevated") + const [error, setError] = createSignal() + return ( + { + const text = input.trim() + const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text) + const invalid = formValidateValue(field, value) + if (invalid) { + setError(invalid) + return + } + resolve(value) + }} + description={() => ( + + + {(description) => {description()}} + + {(value) => {value()}} + + )} + /> + ) + }, + () => resolve(CANCELLED), + ) + }) +} + +async function multiselectAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + const selected = field.default ? [...field.default] : [] + while (true) { + const invalid = formValidateValue(field, selected) + const choice = await new Promise((resolve) => { + dialog.replace( + () => ( + + title={formLabel(field) || title} + options={[ + ...field.options.map((option) => ({ + title: `[${selected.includes(option.value) ? "x" : " "}] ${option.label}`, + value: option.value, + description: option.description, + disabled: + !selected.includes(option.value) && field.maxItems !== undefined && selected.length >= field.maxItems, + })), + ...(field.custom ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }] : []), + { + title: "Continue", + value: SUBMIT as typeof SUBMIT, + description: invalid, + disabled: invalid !== undefined, + }, + ]} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), + ) + }) + if (choice === CANCELLED) return CANCELLED + if (choice === SUBMIT) return selected + if (choice === CUSTOM) { + const value = await customAnswer(dialog, title, field) + if (value === CANCELLED) return CANCELLED + if (value && !selected.includes(value)) selected.push(value) + continue + } + selected.splice(0, selected.length, ...formToggleMultiselect(selected, choice)) + } +} + +function customAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => ( + { + if (value) resolve(value) }} /> ), - () => resolve(null), + () => resolve(CANCELLED), ) - dialog.setSize("large") }) } +async function externalAnswer( + dialog: ReturnType, + title: string, + field: Extract, +): Promise { + let opened = false + while (true) { + const choice = await new Promise((resolve) => { + dialog.replace( + () => ( + + title={formLabel(field) || title} + options={[ + { title: opened ? "Open link again" : "Open link", value: OPEN as typeof OPEN, description: field.url }, + { title: "I finished", value: true as const, description: field.description, disabled: !opened }, + ]} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(CANCELLED), + ) + }) + if (choice === CANCELLED) return CANCELLED + if (choice === true) return true + const result = await new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(CANCELLED), + ) + void open(field.url).then( + () => resolve(true), + () => resolve(false), + ) + }) + if (result === CANCELLED) return CANCELLED + opened ||= result + } +} + async function connected( integration: IntegrationInfo, data: ReturnType, diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index d1f7beb599..8ea3c6f8c9 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -4,7 +4,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" import { useTheme, useThemes } from "../../context/theme" -import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client" +import type { FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useClient } from "../../context/client" import { useClipboard } from "../../context/clipboard" @@ -44,27 +44,6 @@ function requestOptions(form: FormWithLocation) { export function FormPrompt(props: { form: FormWithLocation }) { const client = useClient() - return ( - - client.api.form.reply( - { sessionID: props.form.sessionID, formID: props.form.id, answer }, - requestOptions(props.form), - ) - } - onCancel={() => - client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) - } - /> - ) -} - -export function FormInput(props: { - form: Pick - onSubmit: (answer: FormAnswer) => Promise | void - onCancel: () => Promise | void -}) { const themes = useThemes() const theme = useTheme("elevated") const themeMode = themes.mode @@ -202,14 +181,23 @@ export function FormInput(props: { } function replySingle(field: FormAnswerField, value: FormValue) { - Promise.resolve(props.onSubmit({ [field.key]: value })).catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Invalid answer", + client.api.form + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: { [field.key]: value }, + }, + requestOptions(props.form), ) - }) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) } function pick(value: FormValue, customValue?: string) { @@ -362,7 +350,7 @@ export function FormInput(props: { } function cancel() { - void props.onCancel() + void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) } function openExternal() { @@ -414,23 +402,28 @@ export function FormInput(props: { setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") return } - Promise.resolve( - props.onSubmit( - Object.fromEntries( - fields().flatMap((field) => { - const value = store.answers[field.key] - return value === undefined ? [] : [[field.key, value] as const] - }), - ), - ), - ).catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Invalid answer", + client.api.form + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + }, + requestOptions(props.form), ) - }) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) } onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) @@ -458,7 +451,10 @@ export function FormInput(props: { group: "Form", run: () => { if (textual()) { - void props.onCancel() + void client.api.form.cancel( + { sessionID: props.form.sessionID, formID: props.form.id }, + requestOptions(props.form), + ) return } setStore("editing", false)