From d0ae3b2605fbbe368b992b2db0dcc2f0b180daa8 Mon Sep 17 00:00:00 2001 From: iroaK <55340002+iiroak@users.noreply.github.com> Date: Mon, 25 May 2026 11:22:11 -0400 Subject: [PATCH] feat(tui): add /disconnect command for providers - Add provider.disconnect command to app.tsx - Create DialogProviderDisconnect component - Support API key, config, env, and console-managed providers - Add disabled_providers config update for non-API sources - Include tests for command registration and dialog behavior --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 ++ .../component/dialog-provider-disconnect.tsx | 127 ++++++++++++++++++ .../test/cli/tui/provider-disconnect.test.ts | 37 +++++ 3 files changed, 177 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-provider-disconnect.tsx create mode 100644 packages/opencode/test/cli/tui/provider-disconnect.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 530ba3ff239a..19c3f2514d41 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -42,6 +42,7 @@ import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" +import { DialogProviderDisconnect } from "@tui/component/dialog-provider-disconnect" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -104,6 +105,7 @@ const appBindingCommands = [ "variant.cycle", "variant.list", "provider.connect", + "provider.disconnect", "console.org.switch", "opencode.status", "theme.switch", @@ -624,6 +626,17 @@ function App(props: { onSnapshot?: () => Promise }) { }, category: "Provider", }, + { + name: "provider.disconnect", + title: "Disconnect provider", + suggested: sync.data.provider_next.connected.length > 0, + enabled: sync.data.provider_next.connected.length > 0, + slashName: "disconnect", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider-disconnect.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider-disconnect.tsx new file mode 100644 index 000000000000..0b73345c3146 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider-disconnect.tsx @@ -0,0 +1,127 @@ +import { createMemo, createSignal } from "solid-js" +import { useProject } from "@tui/context/project" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useToast } from "@tui/ui/toast" +import { isConsoleManagedProvider } from "@tui/util/provider-origin" + +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) + return error.message + return JSON.stringify(error) +} + +export function DialogProviderDisconnect() { + const sdk = useSDK() + const sync = useSync() + const project = useProject() + const toast = useToast() + const { theme } = useTheme() + const [pending, setPending] = createSignal() + + const options = createMemo(() => { + if (sync.data.provider_next.connected.length === 0) { + return [ + { + title: "No connected providers", + value: "", + description: "Use /connect to add a provider", + disabled: true, + } satisfies DialogSelectOption, + ] + } + + return sync.data.provider_next.connected.map((providerID) => { + const provider = + sync.data.provider_next.all.find((item) => item.id === providerID) ?? + sync.data.provider.find((item) => item.id === providerID) + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) + const source = provider?.source ?? "api" + const disabled = pending() !== undefined || consoleManaged + + return { + title: + pending() === providerID ? `Disconnecting ${provider?.name ?? providerID}` : (provider?.name ?? providerID), + value: providerID, + description: consoleManaged + ? "Managed by OpenCode Console" + : source === "api" + ? "API key" + : { + config: "Configured provider", + custom: "Custom provider", + env: "Environment credentials", + }[source], + footer: consoleManaged + ? `Managed by ${sync.data.console_state.activeOrgName ?? "OpenCode Console"}` + : providerID, + disabled, + gutter: () => x, + async onSelect(dialog) { + if (disabled) return + setPending(providerID) + const result = await sdk.client.auth.remove({ providerID }).catch((error: unknown) => ({ error })) + if (result.error) { + toast.show({ + variant: "error", + message: errorMessage(result.error), + }) + setPending(undefined) + return + } + + if (source !== "api") { + const disabledProviders = sync.data.config.disabled_providers ?? [] + const update = await sdk.client.config + .update({ + workspace: project.workspace.current(), + config: { + ...sync.data.config, + disabled_providers: disabledProviders.includes(providerID) + ? disabledProviders + : [...disabledProviders, providerID], + }, + }) + .catch((error: unknown) => ({ error })) + if (update.error) { + toast.show({ + variant: "error", + message: errorMessage(update.error), + }) + setPending(undefined) + return + } + } + + await sdk.client.instance.dispose().catch((error) => + toast.show({ + variant: "warning", + message: `Disconnected, but refresh failed: ${errorMessage(error)}`, + }), + ) + await sync.bootstrap({ fatal: false }).catch((error) => + toast.show({ + variant: "warning", + message: `Disconnected, but refresh failed: ${errorMessage(error)}`, + }), + ) + toast.show({ + variant: "info", + message: `${provider?.name ?? providerID} disconnected`, + }) + setPending(undefined) + }, + } satisfies DialogSelectOption + }) + }) + + return +} diff --git a/packages/opencode/test/cli/tui/provider-disconnect.test.ts b/packages/opencode/test/cli/tui/provider-disconnect.test.ts new file mode 100644 index 000000000000..476b57173208 --- /dev/null +++ b/packages/opencode/test/cli/tui/provider-disconnect.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import path from "path" + +const root = path.resolve(import.meta.dir, "../../..") + +test("registers provider disconnect slash command", async () => { + const app = await Bun.file(path.join(root, "src/cli/cmd/tui/app.tsx")).text() + + expect(app).toContain('name: "provider.disconnect"') + expect(app).toContain('slashName: "disconnect"') +}) + +test("provider disconnect dialog removes auth and refreshes sync", async () => { + const dialog = await Bun.file(path.join(root, "src/cli/cmd/tui/component/dialog-provider-disconnect.tsx")).text() + + expect(dialog).toContain("sdk.client.auth.remove") + expect(dialog).toContain("sdk.client.instance.dispose") + expect(dialog).toContain("sync.bootstrap") +}) + +test("provider disconnect dialog handles non-api and managed providers", async () => { + const dialog = await Bun.file(path.join(root, "src/cli/cmd/tui/component/dialog-provider-disconnect.tsx")).text() + + expect(dialog).toContain("disabled_providers") + expect(dialog).toContain("sdk.client.config") + expect(dialog).toContain(".update") + expect(dialog).toContain("isConsoleManagedProvider") + expect(dialog).toContain("Managed by") +}) + +test("provider disconnect dialog guards empty and duplicate submissions", async () => { + const dialog = await Bun.file(path.join(root, "src/cli/cmd/tui/component/dialog-provider-disconnect.tsx")).text() + + expect(dialog).toContain("No connected providers") + expect(dialog).toContain("setPending") + expect(dialog).toContain("Disconnecting") +})