From 05498becb1cbc1e0d79aa2f0a3ec04137be8c307 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 20 Nov 2025 22:02:26 -0500 Subject: [PATCH] tui: support code and auto OAuth flows with improved authorization UI Users can now authenticate providers using both code-based flows (entering an authorization code) and automatic flows. The dialog now clearly displays the authorization URL, allows code entry with retry capability, and shows a waiting indicator during automatic authentication. --- .../cli/cmd/tui/component/dialog-model.tsx | 9 ++- .../cli/cmd/tui/component/dialog-provider.tsx | 80 ++++++++++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 12 ++- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 28 ++++--- .../src/cli/cmd/tui/ui/dialog-select.tsx | 8 +- packages/opencode/src/provider/auth.ts | 13 ++- packages/opencode/src/server/server.ts | 41 ++++++++++ packages/sdk/js/src/gen/sdk.gen.ts | 21 +++++ packages/sdk/js/src/gen/types.gen.ts | 41 ++++++++++ 9 files changed, 219 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 349230b11..5cb5e6898 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -110,9 +110,9 @@ export function DialogModel() { title: provider.name, category: "Popular providers", value: provider.id, - footer: { - opencode: "Recommended", - anthropic: "Claude Max or API key", + description: { + opencode: "(Recommended)", + anthropic: "(Claude Max or API key)", }[provider.id], async onSelect() { const key = await DialogPrompt.show(dialog, "Enter API key") @@ -131,6 +131,7 @@ export function DialogModel() { dialog.replace(() => ) }, })), + filter((x) => PROVIDER_PRIORITY[x.value] !== undefined), sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99), ) : []), @@ -142,7 +143,7 @@ export function DialogModel() { keybind={[ { keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false }, - title: "Connect provider", + title: connected() ? "Connect provider" : "More providers", onTrigger(option) { dialog.replace(() => ) }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 51ae14749..c4236d884 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -5,6 +5,8 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { Dialog, useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" import { DialogPrompt } from "../ui/dialog-prompt" +import { useTheme } from "../context/theme" +import { TextAttributes } from "@opentui/core" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -20,6 +22,7 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() + const { theme } = useTheme() const options = createMemo(() => { return pipe( sync.data.provider_next.all, @@ -69,10 +72,61 @@ export function createDialogProviderOptions() { method: index, }, }) - if (result.data?.method === "code") - await DialogPrompt.show(dialog, result.data.url + " " + result.data.instructions) - if (result.data?.method === "auto") - await DialogPrompt.show(dialog, result.data.url + " " + result.data.instructions) + if (result.data?.method === "code") { + while (true) { + const url = result.data.url + const code = await DialogPrompt.show(dialog, "Login with " + method.label, { + placeholder: "Authorization code", + description: () => ( + + Visit the url to collect your authorization code + {url} + + ), + }) + if (!code) break + const { error } = await sdk.client.provider.oauth.callback({ + path: { + id: provider.id, + }, + body: { + method: index, + code, + }, + }) + if (!error) { + await sdk.client.instance.dispose() + await sync.bootstrap() + return + } + } + } + + if (result.data?.method === "auto") { + const { instructions, url } = result.data + dialog.replace(() => ( + ( + + {url} + {instructions} + + )} + /> + )) + await sdk.client.provider.oauth.callback({ + path: { + id: provider.id, + }, + body: { + method: index, + }, + }) + dialog.clear() + await sdk.client.instance.dispose() + await sync.bootstrap() + } } }, })), @@ -87,3 +141,21 @@ export function DialogProvider() { return } + +interface PendingDialogProps { + title: string + description: () => JSX.Element +} +function PendingDialog(props: PendingDialogProps) { + const { theme } = useTheme() + return ( + + + {props.title} + esc + + {props.description} + Waiting for authorization... + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1ff00133d..17073a969 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -600,11 +600,9 @@ export function Session() { } // Prompt for optional filename - const customFilename = await DialogPrompt.show( - dialog, - "Export filename", - `session-${sessionData.id.slice(0, 8)}.md`, - ) + const customFilename = await DialogPrompt.show(dialog, "Export filename", { + value: `session-${sessionData.id.slice(0, 8)}.md`, + }) // Cancel if user pressed escape if (customFilename === null) return @@ -894,7 +892,7 @@ function UserMessage(props: { const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) - const color = createMemo(() => (queued() ? theme.accent : theme.text)) + const color = createMemo(() => (queued() ? theme.accent : theme.secondary)) const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) @@ -942,7 +940,7 @@ function UserMessage(props: { - + {sync.data.config.username ?? "You"}{" "} JSX.Element + placeholder?: string value?: string onConfirm?: (value: string) => void onCancel?: () => void @@ -37,35 +39,37 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} esc - + + {props.description}