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}
-
- Press enter to confirm, esc to cancel
+
+
+ enter submit
+
+
+ esc cancel
+
)
}
-DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
+DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit) => {
return new Promise((resolve) => {
dialog.replace(
() => (
- resolve(value)}
- onCancel={() => resolve(null)}
- />
+ resolve(value)} onCancel={() => resolve(null)} />
),
() => resolve(null),
)
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index b6b41e262..7beef9b08 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -237,12 +237,14 @@ export function DialogSelect(props: DialogSelectProps) {
)}
-
+
{(item) => (
- {Keybind.toString(item.keybind)}
- {item.title}
+
+ {item.title}{" "}
+
+ {Keybind.toString(item.keybind)}
)}
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
index af18ef600..6467285dd 100644
--- a/packages/opencode/src/provider/auth.ts
+++ b/packages/opencode/src/provider/auth.ts
@@ -73,21 +73,22 @@ export namespace ProviderAuth {
const match = await state().then((s) => s.pending[input.providerID])
if (!match) throw new OauthMissing({ providerID: input.providerID })
let result
+
if (match.method === "code") {
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
result = await match.callback(input.code)
}
+
if (match.method === "auto") {
result = await match.callback()
}
- if (!result) return
- if (result.type === "success") {
+
+ if (result?.type === "success") {
if ("key" in result) {
await Auth.set(input.providerID, {
type: "api",
key: result.key,
})
- return
}
if ("refresh" in result) {
await Auth.set(input.providerID, {
@@ -96,9 +97,11 @@ export namespace ProviderAuth {
refresh: result.refresh,
expires: result.expires,
})
- return
}
+ return
}
+
+ throw new OauthCallbackFailed({})
},
)
@@ -127,4 +130,6 @@ export namespace ProviderAuth {
providerID: z.string(),
}),
)
+
+ export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 5b6104ce0..c6701c239 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1276,6 +1276,47 @@ export namespace Server {
return c.json(result)
},
)
+ .post(
+ "/provider/:id/oauth/callback",
+ describeRoute({
+ description: "Handle OAuth callback for a provider",
+ operationId: "provider.oauth.callback",
+ responses: {
+ 200: {
+ description: "OAuth callback processed successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ id: z.string().meta({ description: "Provider ID" }),
+ }),
+ ),
+ validator(
+ "json",
+ z.object({
+ method: z.number().meta({ description: "Auth method index" }),
+ code: z.string().optional().meta({ description: "OAuth authorization code" }),
+ }),
+ ),
+ async (c) => {
+ const id = c.req.valid("param").id
+ const { method, code } = c.req.valid("json")
+ await ProviderAuth.callback({
+ providerID: id,
+ method,
+ code,
+ })
+ return c.json(true)
+ },
+ )
.get(
"/find",
describeRoute({
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index 2d99e85d6..dc2247990 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -101,6 +101,9 @@ import type {
ProviderOauthAuthorizeData,
ProviderOauthAuthorizeResponses,
ProviderOauthAuthorizeErrors,
+ ProviderOauthCallbackData,
+ ProviderOauthCallbackResponses,
+ ProviderOauthCallbackErrors,
FindTextData,
FindTextResponses,
FindFilesData,
@@ -593,6 +596,24 @@ class Oauth extends _HeyApiClient {
},
})
}
+
+ /**
+ * Handle OAuth callback for a provider
+ */
+ public callback(options: Options) {
+ return (options.client ?? this._client).post<
+ ProviderOauthCallbackResponses,
+ ProviderOauthCallbackErrors,
+ ThrowOnError
+ >({
+ url: "/provider/{id}/oauth/callback",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ })
+ }
}
class Provider extends _HeyApiClient {
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 08e139340..59e5a5d0c 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -2588,6 +2588,47 @@ export type ProviderOauthAuthorizeResponses = {
export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]
+export type ProviderOauthCallbackData = {
+ body?: {
+ /**
+ * Auth method index
+ */
+ method: number
+ /**
+ * OAuth authorization code
+ */
+ code?: string
+ }
+ path: {
+ /**
+ * Provider ID
+ */
+ id: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/provider/{id}/oauth/callback"
+}
+
+export type ProviderOauthCallbackErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]
+
+export type ProviderOauthCallbackResponses = {
+ /**
+ * OAuth callback processed successfully
+ */
+ 200: boolean
+}
+
+export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
+
export type FindTextData = {
body?: never
path?: never