-
-
{language.t("provider.connect.status.waiting")}
+
+ )
+ }
+
+ const startWithModel = async () => {
+ const model = selectedModel()
+ if (!model) return
+ const selection = { providerID: model.providerID, modelID: model.id }
+ if (props.selection) {
+ props.selection.set(selection)
+ done()
+ return
+ }
+ dialog.close()
+ await tabs.newDraft(
+ {
+ server: ServerConnection.key(sdk.server),
+ directory: initialDirectory ?? data.location.default().directory,
+ },
+ undefined,
+ selection,
+ )
+ }
+
+ function FirstConnectionModels() {
+ return (
+
+
+ {language.t("provider.connect.models.description")}
+
+
+
+
+ {(group) => {
+ const collapsible = () => connectionGroups().length > 1
+ const expanded = () => !collapsible() || !consoleState.collapsed[group.provider.id]
+ const label = () => (
+
+ }
+ >
+
+
+ {group.provider.name}
+
+ )
+ return (
+
}>
+
+
+
+
+
+
+ {(model) => {
+ const selected = () => consoleState.selectedModel === modelKey(model)
+ return (
+
+ )
+ }}
+
+
+
+
+ )
+ }}
+
+
+
+
+
+
)
}
return (
-
-
-
-
-
-
- {language.t("provider.connect.title.anthropicProMax")}
-
- {language.t("provider.connect.title", { provider: provider().name })}
-
-
-
+
}>
-
-
-
-
-
-
- {language.t("provider.connect.status.inProgress")}
-
-
-
-
-
-
-
-
-
-
- {language.t("provider.connect.status.inProgress")}
-
-
-
-
-
-
-
-
-
-
- {language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
-
-
-
-
-
-
-
+
+
}
+ >
+
+
+
+
-
-
-
-
-
+ {language.t("provider.connect.console.title")}
+
+ {language.t("provider.connect.title.anthropicProMax")}
+ {language.t("provider.connect.title", { provider: provider().name })}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {language.t("provider.connect.console.connected")}
+
+
+ {language.t(
+ consoleModels().length ? "provider.connect.console.ready" : "provider.connect.console.noModels",
+ )}
+
+
+
+ {(model) => (
+
+ {language.t("provider.connect.console.model")}{" "}
+ {model().name}
+
+ )}
+
+
0}
+ fallback={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {language.t("provider.connect.console.refreshing")}
+
+
+
+
+
+
+ {language.t("provider.connect.status.inProgress")}
+
+
+
+
+
+
+
+
+
+
+ {language.t("provider.connect.status.inProgress")}
+
+
+
+
+
+
+
+
+
+
+
+ {desktopConsole
+ ? controller.auth.error()
+ : language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
)
}
diff --git a/packages/app/src/providers/connect/models.css b/packages/app/src/providers/connect/models.css
new file mode 100644
index 000000000000..e7127b89acdb
--- /dev/null
+++ b/packages/app/src/providers/connect/models.css
@@ -0,0 +1,17 @@
+[data-first-provider-model]:has(+ [data-first-provider-model]:hover),
+[data-first-provider-model]:hover {
+ border-block-end-color: transparent;
+}
+
+[data-first-provider-model]:hover {
+ background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+[data-component="first-provider-models"] [data-component="settings-list"] {
+ background-color: var(--v2-background-bg-layer-02);
+ overflow: hidden;
+}
+
+[data-component="first-provider-models"] .settings-section[data-expanded] {
+ padding-bottom: 0;
+}
diff --git a/packages/app/src/providers/connect/remote.test.ts b/packages/app/src/providers/connect/remote.test.ts
new file mode 100644
index 000000000000..a34d72dce968
--- /dev/null
+++ b/packages/app/src/providers/connect/remote.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "bun:test"
+import { authServerName } from "./remote"
+
+test("SSH disclosure uses the remote identity even with a loopback proxy", () => {
+ expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe(
+ "production.example",
+ )
+ expect(
+ authServerName({
+ type: "ssh",
+ host: "production.example",
+ displayName: "Production server",
+ http: { url: "http://127.0.0.1:4096" },
+ }),
+ ).toBe("Production server")
+})
+
+test("local Desktop and loopback HTTP connections do not show remote disclosure", () => {
+ expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined()
+ for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
+ expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined()
+ }
+})
+
+test("WSL and remote HTTP connections show their server identity", () => {
+ expect(
+ authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }),
+ ).toBe("Ubuntu")
+ expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example")
+})
diff --git a/packages/app/src/providers/connect/remote.tsx b/packages/app/src/providers/connect/remote.tsx
new file mode 100644
index 000000000000..97133307808f
--- /dev/null
+++ b/packages/app/src/providers/connect/remote.tsx
@@ -0,0 +1,30 @@
+import { Show } from "solid-js"
+import { useLanguage } from "@/runtime/i18n/language"
+import { ServerConnection, serverName } from "@/runtime/server/registry"
+
+export function authServerName(server: ServerConnection.Any) {
+ if (ServerConnection.builtin(server)) return undefined
+ if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname))
+ return undefined
+ if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro
+ return serverName(server)
+}
+
+export function RemoteAuthNotice(props: { server: ServerConnection.Any }) {
+ const language = useLanguage()
+ return (
+
+ {(name) => (
+
+
+ {language.t("provider.connect.remote.title", { server: name() })}
+
+
{language.t("provider.connect.remote.description")}
+
+ )}
+
+ )
+}
diff --git a/packages/app/src/providers/connect/setup.tsx b/packages/app/src/providers/connect/setup.tsx
new file mode 100644
index 000000000000..191dcc23acc7
--- /dev/null
+++ b/packages/app/src/providers/connect/setup.tsx
@@ -0,0 +1,51 @@
+import { Button } from "@opencode/ui/button"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { useLanguage } from "@/runtime/i18n/language"
+import { useServerSDK } from "@/runtime/server/client"
+import type { ModelSelection } from "@/providers/models/selection"
+import { RemoteAuthNotice } from "./remote"
+import { Show } from "solid-js"
+
+export function ProviderSetup(props: {
+ visible: boolean
+ directory: string
+ selection: ModelSelection
+ onDone: () => void
+}) {
+ const language = useLanguage()
+ const dialog = useDialog()
+ const sdk = useServerSDK()
+ const open = async (console: boolean) => {
+ const { DialogConnectProvider } = await import("./dialog")
+ void dialog.show(() => (
+
+ ))
+ }
+ return (
+
+
+
+
{language.t("provider.setup.title")}
+
{language.t("provider.connect.console.intro")}
+
+
+
+
+
+
+ {language.t("provider.setup.settings")}
+
+
+ )
+}
diff --git a/packages/app/src/providers/opencode-logo.tsx b/packages/app/src/providers/opencode-logo.tsx
new file mode 100644
index 000000000000..227257fa91e5
--- /dev/null
+++ b/packages/app/src/providers/opencode-logo.tsx
@@ -0,0 +1,17 @@
+export function OpenCodeLogo(props: { class?: string }) {
+ return (
+
+ )
+}
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index ccb43658116e..441fd744aeb2 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -190,6 +190,53 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
+ "provider.connect.console.title": "Connect OpenCode Console account",
+ "provider.connect.console.name": "OpenCode Console",
+ "provider.connect.console.instructions":
+ "Continue in your browser. Confirm the code shown there matches the one below.",
+ "provider.connect.console.deviceCode": "Device code",
+ "provider.connect.console.waiting": "Waiting for confirmation…",
+ "provider.connect.console.browserHint": "Browser didn't open?",
+ "provider.connect.console.copyLink": "Copy sign-in link",
+ "provider.connect.console.linkCopied": "Sign-in link copied",
+ "provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.",
+ "provider.connect.console.openAgain": "Open Console again",
+ "provider.connect.console.browserFailed":
+ "We couldn't open your browser. Try again or copy the sign-in link to continue.",
+ "provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.",
+ "provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.",
+ "provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.",
+ "provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.",
+ "provider.connect.models.title": "Connected to {{provider}}",
+ "provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
+ "provider.connect.models.list": "Models available from {{provider}}",
+ "provider.connect.console.retry": "Try again",
+ "provider.connect.console.refreshing": "OpenCode connected. Loading your models...",
+ "provider.connect.console.refreshFailed":
+ "Your account is connected, but we couldn't load your models. Try again to refresh them.",
+ "provider.connect.console.connected": "OpenCode connected",
+ "provider.connect.console.ready": "Your models are ready.",
+ "provider.connect.console.noModels":
+ "Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
+ "provider.connect.console.refresh": "Refresh models",
+ "provider.connect.console.model": "Model",
+ "provider.connect.console.start": "Start coding",
+ "provider.connect.console.done": "Done",
+ "provider.connect.console.continue": "Continue with OpenCode Console",
+ "provider.connect.console.openingBrowser": "Opening browser…",
+ "provider.connect.console.serviceAccount": "Service account?",
+ "provider.connect.console.useApiKey": "Use API key",
+ "provider.connect.console.otherMethods": "Other methods",
+ "provider.connect.console.serviceKey": "API key (service account)",
+ "provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
+ "provider.connect.console.intro": "Sign in once to use the models available through your OpenCode account.",
+ "provider.connect.remote.title": "Connecting on “{{server}}”",
+ "provider.connect.remote.description":
+ "Your OpenCode credentials will be stored on this server. Models will be available through this server.",
+ "provider.connect.form.unsupported": "This authentication form contains unsupported fields",
+ "provider.setup.title": "Start with OpenCode",
+ "provider.setup.other": "Other providers",
+ "provider.setup.settings": "You can change this later in Settings → Providers.",
"provider.connect.title.anthropicProMax": "Login with Claude Pro/Max",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
@@ -1314,6 +1361,8 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
+ "settings.providers.console.available.one": "{{count}} provider available",
+ "settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
"settings.providers.tag.environment": "Environment",
diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx
index a8a14bc8eb92..0507ae3771f6 100644
--- a/packages/app/src/runtime/platform/platform.tsx
+++ b/packages/app/src/runtime/platform/platform.tsx
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
+ /** Open an authentication page, reporting whether the browser could be launched. */
+ openBrowser?(url: string): Promise
+
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise
diff --git a/packages/desktop/src/main/ipc-handlers/files.ts b/packages/desktop/src/main/ipc-handlers/files.ts
index d03b7f6010a3..884c31fcb0d2 100644
--- a/packages/desktop/src/main/ipc-handlers/files.ts
+++ b/packages/desktop/src/main/ipc-handlers/files.ts
@@ -1,4 +1,6 @@
import { Effect } from "effect"
+import { shell } from "electron"
+import { resolveExternalURL } from "../files/external-url"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
@@ -18,13 +20,22 @@ export const fileHandlers = FileRpcs.toLayer(
)
.pipe(Effect.orDie),
FilesReadPickedFile: ({ token, path }, context) =>
- files
- .readPickedFile(sender(handoff, context).id, token, path)
- .pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
+ files.readPickedFile(sender(handoff, context).id, token, path).pipe(
+ Effect.map((buffer) => new Uint8Array(buffer)),
+ Effect.orDie,
+ ),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFile: ({ options, content }) => files.saveFile(options, content).pipe(Effect.orDie),
FilesOpenExternal: ({ url }) => openExternalURL(url),
+ FilesOpenBrowser: ({ url }) => {
+ const target = resolveExternalURL(url)
+ if (!target || !/^https?:/.test(target)) return Effect.succeed(false)
+ return Effect.tryPromise(() => shell.openExternal(target)).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ )
+ },
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts
index 3fe255855ea4..4b233b988e46 100644
--- a/packages/desktop/src/renderer/api-types.ts
+++ b/packages/desktop/src/renderer/api-types.ts
@@ -63,6 +63,7 @@ export type ElectronAPI = {
getPathForFile(file: File): string
saveFile(opts: SaveFilePickerOptions, content: string): Promise
openExternal(url: string): void
+ openBrowser(url: string): Promise
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise
revealPath(path: string): Promise
diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts
index 8859413cb568..419d4f0db997 100644
--- a/packages/desktop/src/renderer/api.ts
+++ b/packages/desktop/src/renderer/api.ts
@@ -122,6 +122,7 @@ export const api: ElectronAPI = {
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFile: (opts, content) => invoke("FilesSaveFile", { options: opts, content }),
openExternal: (url) => send("FilesOpenExternal", { url }),
+ openBrowser: (url) => invoke("FilesOpenBrowser", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
diff --git a/packages/desktop/src/renderer/platform/files.test.ts b/packages/desktop/src/renderer/platform/files.test.ts
index 8ac4e418cda8..7e3c5cfa9e7f 100644
--- a/packages/desktop/src/renderer/platform/files.test.ts
+++ b/packages/desktop/src/renderer/platform/files.test.ts
@@ -21,6 +21,7 @@ function fileApi(events: string[]) {
getPathForFile: () => "fallback",
saveFile: async () => false,
openExternal: () => {},
+ openBrowser: async () => true,
openLocalFile: () => {},
resolveAppPath: async () => null,
openPath: async () => undefined,
@@ -33,6 +34,10 @@ function fileApi(events: string[]) {
}
describe("desktop attachment files", () => {
+ test("reports native browser launch failure to the renderer", async () => {
+ const files = createDesktopFiles({ ...fileApi([]), openBrowser: async () => false }, "macos", [])
+ expect(await files.openBrowser("https://opencode.ai/console")).toBe(false)
+ })
test("reads selected files sequentially and releases the token", async () => {
const events: string[] = []
const files = createDesktopFiles(fileApi(events), "windows", ["txt"])
diff --git a/packages/desktop/src/renderer/platform/files.ts b/packages/desktop/src/renderer/platform/files.ts
index 401bb59bcaca..577735883dc2 100644
--- a/packages/desktop/src/renderer/platform/files.ts
+++ b/packages/desktop/src/renderer/platform/files.ts
@@ -11,6 +11,7 @@ type DesktopFileAPI = Pick<
| "getPathForFile"
| "saveFile"
| "openExternal"
+ | "openBrowser"
| "openLocalFile"
| "resolveAppPath"
| "openPath"
@@ -55,6 +56,7 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedE
saveFile: (options: { title?: string; defaultPath?: string }, content: string) =>
api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content),
openExternal: (url: string) => api.openExternal(url),
+ openBrowser: (url: string) => api.openBrowser(url),
openLocalFile: (url: string) => api.openLocalFile(url),
async openPath(path: string, app?: string) {
if (os !== "windows") {
diff --git a/packages/desktop/src/shared/ipc-rpc/files.ts b/packages/desktop/src/shared/ipc-rpc/files.ts
index 3c6afa24b8b7..828b7b1d5930 100644
--- a/packages/desktop/src/shared/ipc-rpc/files.ts
+++ b/packages/desktop/src/shared/ipc-rpc/files.ts
@@ -42,6 +42,10 @@ export const FilesSaveFile = Rpc.make("FilesSaveFile", {
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
+export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", {
+ payload: { url: Schema.String },
+ success: Schema.Boolean,
+})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
@@ -67,6 +71,7 @@ export const FileRpcs = RpcGroup.make(
FilesReleasePickedFiles,
FilesSaveFile,
FilesOpenExternal,
+ FilesOpenBrowser,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
diff --git a/packages/ui/src/overlays/dialog/dialog.tsx b/packages/ui/src/overlays/dialog/dialog.tsx
index 646e2e3c043e..afca7d62f7bd 100644
--- a/packages/ui/src/overlays/dialog/dialog.tsx
+++ b/packages/ui/src/overlays/dialog/dialog.tsx
@@ -10,6 +10,7 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
+ onCloseAutoFocus?: ComponentProps["onCloseAutoFocus"]
}
export interface DialogHeaderProps extends ParentProps {
@@ -82,7 +83,16 @@ export function DialogHeader(props: DialogHeaderProps) {
}
export function Dialog(props: DialogProps) {
- const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"])
+ const [local] = splitProps(props, [
+ "size",
+ "variant",
+ "class",
+ "containerClass",
+ "classList",
+ "fit",
+ "children",
+ "onCloseAutoFocus",
+ ])
return (
Date: Fri, 11 Sep 2026 16:38:14 +0200
Subject: [PATCH 2/6] feat(app): group Console providers in settings
---
packages/app/src/settings/models/models.tsx | 27 ++-
.../app/src/settings/providers/providers.tsx | 224 +++++++++++++++---
packages/app/src/settings/settings.css | 132 +++++++++++
packages/app/src/settings/shell.tsx | 41 +++-
4 files changed, 383 insertions(+), 41 deletions(-)
diff --git a/packages/app/src/settings/models/models.tsx b/packages/app/src/settings/models/models.tsx
index 1d1fc843b1d4..abf93dc949af 100644
--- a/packages/app/src/settings/models/models.tsx
+++ b/packages/app/src/settings/models/models.tsx
@@ -24,7 +24,12 @@ export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
-export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
+export const SettingsModels: Component<{
+ active?: boolean
+ autofocus?: boolean
+ provider?: string
+ onReveal?: () => void
+}> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -47,6 +52,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
ModelProvidersSchema,
{ collapsed: {} },
)
+ const sections = new Map()
const list = useFilteredList({
items: (_filter) => models.list(),
@@ -70,6 +76,24 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
},
})
+ createEffect(() => {
+ if (!props.active || !props.provider) return
+ const provider = props.provider
+ if (list.filter()) {
+ list.clear()
+ return
+ }
+ if (!list.grouped.latest.some((group) => group.category === provider)) return
+ const section = sections.get(provider)
+ if (!section?.isConnected) return
+ list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider))
+ requestAnimationFrame(() => {
+ section.scrollIntoView({ block: "start" })
+ section.querySelector(".settings-models-group-trigger")?.focus({ preventScroll: true })
+ props.onReveal?.()
+ })
+ })
+
return (
<>