Compare commits

...
Author SHA1 Message Date
LukeParkerDev ee0a316593 fix(app): drop the Go-inactive toast after Console sign-in
Console sign-in returns Go models inside the opencode provider, so checking
for an opencode-go provider always failed and produced a misleading toast.
2026-09-21 14:14:24 +10:00
LukeParkerDev 5688dd17c5 feat(app): sign in to OpenCode Go and Console through the browser
OpenCode Go and OpenCode (Zen) now start the Console device sign-in as soon as
they are picked, open the browser automatically, and keep the API key path
behind an Advanced toggle. The dialog holds one stable state until the
authorization URL is ready, hidden form fields no longer render as empty
inputs, abandoned attempts are cancelled, and models unlocked by the new
connection are shown in the picker. Adds a /connect command matching the TUI
and the Console setup steps.
2026-09-21 12:30:08 +10:00
5 changed files with 326 additions and 113 deletions
+15
View File
@@ -5,6 +5,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
import { decode64 } from "@/runtime/persistence/base64"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -23,6 +24,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
const providerCommand = withCategory(language.t("command.category.provider"))
// Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run.
const connectProvider = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={decode64(local.slug())} />)
}
const chooseModel = async () => {
const owner = sessionOwnership.capture()
@@ -62,6 +70,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
keybind: "shift+mod+d",
onSelect: () => model.variant.cycle(),
}),
providerCommand({
id: "provider.connect",
title: language.t("command.provider.connect"),
description: language.t("command.provider.connect.description"),
slash: "connect",
onSelect: connectProvider,
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
@@ -9,10 +9,29 @@ import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"])
export function consoleIntegration(provider: string) {
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
}
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
return Object.fromEntries(
(method?.form ?? []).flatMap((field) =>
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
),
) as FormAnswer
}
export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
/** Picks the method to start without asking when the integration exposes several. */
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
pollInterval?: number
}) {
const language = useLanguage()
@@ -43,15 +62,24 @@ export function createProviderConnectionController(options: {
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
auto: false,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
)
const autoIndex = createMemo(() => {
if (integration.loading) return undefined
const values = methods()
if (values.length === 1) return 0
return options.autoSelect?.(values)
})
type Action =
| { type: "method.select"; index: number }
@@ -109,6 +137,14 @@ export function createProviderConnectionController(options: {
)
}
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
if (!attempt) return
void serverSDK.api.integration.oauth
.cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() })
.catch(() => undefined)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
@@ -117,6 +153,7 @@ export function createProviderConnectionController(options: {
}
const finish = async () => {
cancelPolling()
polling.attempt = undefined
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
@@ -140,6 +177,7 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
polling.attempt = undefined
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
@@ -151,26 +189,35 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
polling.attempt = undefined
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const open = () => {
const url = store.authorization?.url
if (url) platform.openExternal(url)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
if (visible && !answer) {
dispatch({ type: "auth.form" })
return
}
const merged = { ...hiddenDefaults(selected), ...answer }
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer })
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
return
}
if (selected.type !== "oauth") return
@@ -183,11 +230,11 @@ export function createProviderConnectionController(options: {
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
...(Object.keys(merged).length ? { answer: merged } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
@@ -195,16 +242,39 @@ export function createProviderConnectionController(options: {
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
if (polling.disposed || generation !== polling.generation) {
if (result.ok)
void serverSDK.api.integration.oauth
.cancel({
integrationID: options.provider(),
attemptID: result.authorization.attemptID,
location: location(),
})
.catch(() => undefined)
return
}
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
})
return
}
polling.attempt = result.authorization
dispatch({ type: "auth.complete", authorization: result.authorization })
// Same as `opencode auth login`: hand the user straight to the browser instead of
// asking them to click a link and retype a code.
platform.openExternal(result.authorization.url)
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const retry = () => {
const index = store.methodIndex
if (index === undefined) return
void select(index, store.formAnswer)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
@@ -236,15 +306,16 @@ export function createProviderConnectionController(options: {
return undefined
}
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
const index = autoIndex()
if (store.auto || index === undefined) return
setStore("auto", true)
void select(index)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -254,11 +325,19 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
busy: () =>
integration.loading ||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
store.state === "pending",
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
retry,
open,
connectKey,
completeCode,
},
+182 -100
View File
@@ -8,16 +8,23 @@ import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
import {
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
type ProviderConnectMethod,
} from "./controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -258,13 +265,39 @@ function ProviderConnection(props: {
const dialog = useDialog()
const params = useParams()
const language = useLanguage()
const data = useData()
const global = useGlobal()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const integrationID = () => consoleIntegration(props.provider)
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
const controller = createProviderConnectionController({
provider: () => props.provider,
provider: integrationID,
directory,
autoSelect: (methods) => {
if (!isConsole()) return undefined
const index = methods.findIndex((method) => method.type === "oauth")
return index === -1 ? undefined : index
},
onComplete: () => {
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
const linked = (data.location.provider.list(location()) ?? []).filter(
(item) => item.id === props.provider || item.integrationID === integrationID(),
)
const ids = new Set(linked.map((item) => item.id))
global.models.show(
(data.location.model.list(location()) ?? [])
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
dialog.close()
showToast({
variant: "success",
@@ -276,7 +309,11 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name:
integrations.list().find((item) => item.id === props.provider)?.name ??
providers.all().get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -298,6 +335,13 @@ function ProviderConnection(props: {
: undefined,
}
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
return instructions
})
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
function AuthFormView() {
const [formStore, setFormStore] = createStore({
@@ -307,7 +351,7 @@ function ProviderConnection(props: {
const fields = createMemo<StringForm[]>(() => {
const value = controller.currentMethod()
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
})
const matches = (field: StringForm, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => {
@@ -364,7 +408,7 @@ function ProviderConnection(props: {
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
@@ -422,7 +466,13 @@ function ProviderConnection(props: {
}
function goBack() {
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
// The API key path for the Console is an escape hatch below the sign-in flow, so
// "back" returns to the sign-in rather than leaving the provider.
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
void controller.auth.select(oauthIndex())
return
}
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
controller.auth.reset()
return
}
@@ -463,6 +513,31 @@ function ProviderConnection(props: {
)
}
function StatusRow(input: { children: JSX.Element }) {
return (
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span>{input.children}</span>
</div>
)
}
function ErrorRow() {
return (
<div class="flex flex-col items-start gap-3">
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => controller.auth.retry()}>
{language.t("common.retry")}
</Button>
</div>
)
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
@@ -494,22 +569,17 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
when={isConsole()}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -574,13 +644,10 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
@@ -613,34 +680,60 @@ function ProviderConnection(props: {
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t(
isConsole() ? "provider.connect.console.description" : "provider.connect.oauth.auto.description",
{ provider: provider().name },
)}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<Show when={code()}>
{(value) => (
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={value()}
readOnly
copyable
/>
)}
</Show>
</div>
)
}
// Deliberately quiet: most people should never need a key, so it only appears after
// opening "Advanced".
function ConsoleAdvanced() {
const [store, setStore] = createStore({ open: false })
return (
<div class="mt-auto flex flex-col items-end gap-1 px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
<button
type="button"
class="rounded-xs px-1 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
aria-expanded={store.open}
onClick={() => setStore("open", (open) => !open)}
>
{language.t("provider.connect.console.advanced")}
</button>
<Show when={store.open}>
<button
type="button"
data-action="provider-connect-api-key"
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.apiKey.switch")}
</button>
</Show>
</div>
)
}
@@ -661,53 +754,42 @@ function ProviderConnection(props: {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
<Switch>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>
{language.t(
isConsole() && controller.methodIndex() !== undefined
? "provider.connect.console.opening"
: "provider.connect.status.inProgress",
)}
</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show when={isConsole() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}>
<ConsoleAdvanced />
</Show>
</div>
</div>
)
+18
View File
@@ -87,6 +87,7 @@ export const dict = {
"command.project.index": "Switch to project {{index}}",
"command.project.copyID": "Copy Project ID",
"command.provider.connect": "Connect provider",
"command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
"command.session.previous": "Previous session",
@@ -225,6 +226,22 @@ export const dict = {
"provider.connect.oauth.auto.visit.suffix":
" and enter the code below to connect your account and use {{provider}} models in OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Confirmation code",
"provider.connect.oauth.auto.confirmationCode.description":
"Check that your browser shows the same code before you authorize.",
"provider.connect.oauth.auto.description":
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
"provider.connect.oauth.code.description":
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
"provider.connect.oauth.expired": "Authorization expired",
"provider.connect.console.description":
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
"provider.connect.console.opening": "Opening your browser…",
"provider.connect.console.advanced": "Advanced",
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
"provider.connect.console.apiKey.description":
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
"provider.connect.console.apiKey.link": "Open the Console",
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
@@ -302,6 +319,7 @@ export const dict = {
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.retry": "Try again",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving…",
+20 -1
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -98,6 +98,25 @@ function createGlobalModels() {
set: setStore,
ready,
recent: () => recent()!,
// Marks models visible in the picker regardless of the "latest per family" default.
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
batch(() => {
for (const model of models) {
const index = seen.get(`${model.providerID}:${model.modelID}`)
if (index !== undefined) {
setStore("user", index, "visibility", "show")
continue
}
seen.set(`${model.providerID}:${model.modelID}`, store.user.length)
setStore("user", store.user.length, {
providerID: model.providerID,
modelID: model.modelID,
visibility: "show",
})
}
})
},
}
}