feat(ui): add instance-scoped provider manager from model selector (#476)

## Summary
- move provider authentication management out of global settings and
into the model selector so it always runs against a live OpenCode
instance
- add an instance-scoped provider manager modal with API-key and OAuth
flows, cancellable OAuth waiting, and provider discovery/configuration
details
- handle configured provider sources differently so config- and
env-backed providers are explained in the UI while auth-backed providers
can be disconnected safely

## Validation
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
This commit is contained in:
Shantur Rathore 2026-05-17 18:18:41 +01:00 committed by GitHub
parent 02c26226ab
commit 4ddd25602e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1548 additions and 6 deletions

View file

@ -1,11 +1,12 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown, Star } from "lucide-solid"
import { ChevronDown, PlugZap, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
import { ProviderManagerModal } from "./provider-auth/provider-manager-modal"
const log = getLogger("session")
interface ModelSelectorProps {
@ -52,6 +53,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const [initialQuery, setInitialQuery] = createSignal("")
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
const [providersModalOpen, setProvidersModalOpen] = createSignal(false)
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
let listboxRef!: HTMLUListElement
@ -404,6 +406,27 @@ export default function ModelSelector(props: ModelSelectorProps) {
</div>
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
<div class="selector-footer">
<button
type="button"
class="selector-option selector-option-action w-full"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setIsOpen(false)
setProvidersModalOpen(true)
}}
>
<PlugZap class="w-4 h-4" />
<span class="selector-option-label">{t("modelSelector.manageProviders")}</span>
</button>
<button
type="button"
class="selector-option selector-option-action w-full"
@ -428,6 +451,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<ProviderManagerModal instanceId={props.instanceId} open={providersModalOpen()} onOpenChange={setProvidersModalOpen} />
</div>
)
}

View file

@ -0,0 +1,650 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js"
import { Check, ChevronDown, ExternalLink, KeyRound, Loader2, PlugZap, RefreshCw, ShieldCheck, X } from "lucide-solid"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { openExternalUrl } from "../../lib/external-url"
import { useI18n } from "../../lib/i18n"
import { requestData } from "../../lib/opencode-api"
import { isTauriHost } from "../../lib/runtime-env"
import {
extractProviderAuthErrorMessage,
genericApiMethod,
isAbortError,
shouldShowProviderAuthPrompt,
type ProviderAuthAuthorization,
type ProviderAuthMethod,
} from "../../lib/provider-auth"
import { instances } from "../../stores/instances"
import { fetchProviders } from "../../stores/sessions"
type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error"
type MethodOption = {
value: string
label: string
method: ProviderAuthMethod
index: number
}
type ConfigurableProviderOption = {
id: string
name: string
modelCount: number
connectionSummary: string
}
type ListedProvider = {
id: string
name: string
modelCount: number
source: "env" | "config" | "custom" | "api" | "unknown"
}
type DisconnectMode = "auth-remove" | "disable-in-config" | "not-disconnectable" | "unknown"
interface ProviderManagerModalProps {
instanceId: string
open: boolean
onOpenChange: (open: boolean) => void
}
function modelCountFromProvider(provider: any): number {
const models = provider?.models
if (Array.isArray(models)) return models.length
if (models && typeof models === "object") return Object.keys(models).length
return 0
}
export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props) => {
const { t } = useI18n()
const [methodsByProvider, setMethodsByProvider] = createSignal<Record<string, ProviderAuthMethod[]>>({})
const [availableProviders, setAvailableProviders] = createSignal<ListedProvider[]>([])
const [connectedProviderIds, setConnectedProviderIds] = createSignal<Set<string>>(new Set())
const [configuredProviderIds, setConfiguredProviderIds] = createSignal<Set<string>>(new Set())
const [configData, setConfigData] = createSignal<Record<string, any>>({})
const [selectedProviderId, setSelectedProviderId] = createSignal<string | null>(null)
const [activeProviderId, setActiveProviderId] = createSignal<string | null>(null)
const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0)
const [apiKey, setApiKey] = createSignal("")
const [promptValues, setPromptValues] = createSignal<Record<string, string>>({})
const [authorization, setAuthorization] = createSignal<ProviderAuthAuthorization | null>(null)
const [code, setCode] = createSignal("")
const [stage, setStage] = createSignal<AuthStage>("idle")
const [loading, setLoading] = createSignal(false)
const [loadError, setLoadError] = createSignal<string | null>(null)
const [actionError, setActionError] = createSignal<string | null>(null)
const [authorizationLaunchBlocked, setAuthorizationLaunchBlocked] = createSignal(false)
const [authorizationLinkCopied, setAuthorizationLinkCopied] = createSignal(false)
let callbackAbortController: AbortController | null = null
let pendingOauthPopup: Window | null = null
const instance = createMemo(() => instances().get(props.instanceId) ?? null)
const client = createMemo<OpencodeClient | null>(() => instance()?.client ?? null)
const providerNameById = createMemo(() => {
const names = new Map<string, string>()
for (const provider of availableProviders()) {
names.set(provider.id, provider.name || provider.id)
}
return names
})
const configurableProviders = createMemo<ConfigurableProviderOption[]>(() => {
const ids = new Set<string>()
for (const provider of availableProviders()) ids.add(provider.id)
for (const id of Object.keys(methodsByProvider())) ids.add(id)
return Array.from(ids)
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" }))
.map((id) => {
const listed = availableProviders().find((provider) => provider.id === id)
return {
id,
name: providerNameById().get(id) ?? id,
modelCount: listed?.modelCount ?? 0,
connectionSummary: methodSummary(id),
}
})
})
const configuredProviders = createMemo(() =>
availableProviders().filter((provider) => isConfiguredProvider(provider)),
)
const getDisconnectMode = (provider: ListedProvider): DisconnectMode => {
if (provider.source === "env") return "not-disconnectable"
if (provider.source === "config" || configuredProviderIds().has(provider.id)) return "disable-in-config"
if (provider.source === "api" || provider.source === "custom") return "auth-remove"
return "unknown"
}
const isConfiguredProvider = (provider: ListedProvider) =>
connectedProviderIds().has(provider.id) ||
provider.source === "env" ||
provider.source === "config" ||
provider.source === "api" ||
configuredProviderIds().has(provider.id)
const describeProviderSource = (provider: ListedProvider) => {
const mode = getDisconnectMode(provider)
if (mode === "disable-in-config") return t("settings.providers.source.config")
if (mode === "not-disconnectable") return t("settings.providers.source.env")
if (provider.source === "api") return t("settings.providers.source.api")
if (provider.source === "custom") return t("settings.providers.source.custom")
return t("settings.providers.source.unknown")
}
const selectedProviderOption = createMemo(() =>
configurableProviders().find((provider) => provider.id === selectedProviderId()) ?? configurableProviders()[0] ?? null,
)
const activeProviderName = createMemo(() => {
const providerId = activeProviderId()
return providerId ? providerNameById().get(providerId) ?? providerId : ""
})
const activeMethods = createMemo(() => {
const providerId = activeProviderId()
if (!providerId) return [genericApiMethod]
const methods = methodsByProvider()[providerId]
return methods && methods.length > 0 ? methods : [genericApiMethod]
})
const methodOptions = createMemo<MethodOption[]>(() =>
activeMethods().map((method, index) => ({
value: String(index),
label: method.label || (method.type === "oauth" ? t("settings.providers.method.oauth") : t("settings.providers.method.api")),
method,
index,
})),
)
const selectedMethodOption = createMemo(() => methodOptions().find((option) => option.index === selectedMethodIndex()) ?? methodOptions()[0])
const selectedMethod = createMemo(() => selectedMethodOption()?.method ?? genericApiMethod)
const oauthPromptsUnsupported = createMemo(() => selectedMethod().type === "oauth" && (selectedMethod().prompts?.length ?? 0) > 0)
const visiblePrompts = createMemo(() =>
(selectedMethod().prompts ?? []).filter((prompt) => shouldShowProviderAuthPrompt(prompt, promptValues())),
)
const canSubmit = createMemo(() => {
if (!activeProviderId()) return false
if (stage() === "authorizing" || stage() === "waiting" || stage() === "success") return false
if (oauthPromptsUnsupported()) return false
if (selectedMethod().type === "api") return apiKey().trim().length > 0
return visiblePrompts().every((prompt) => (promptValues()[prompt.key] ?? "").trim().length > 0)
})
function handleModalOpenChange(open: boolean) {
if (!open) resetFlow(null)
props.onOpenChange(open)
}
function isBrowserHostForOAuth(): boolean {
return !isTauriHost() && typeof window !== "undefined"
}
function prepareOAuthPopupWindow(): Window | null {
if (!isBrowserHostForOAuth()) {
return null
}
try {
const popup = window.open("", "_blank")
if (popup && popup.document) {
popup.document.title = t("settings.providers.oauth.popup.loadingTitle")
popup.document.body.innerHTML = `<div style=\"font-family: sans-serif; padding: 24px; color: #111;\">${t("settings.providers.oauth.popup.loadingBody")}</div>`
}
return popup
} catch {
return null
}
}
async function launchAuthorizationUrl(url: string, options?: { popup?: Window | null; sameTab?: boolean }): Promise<boolean> {
if (options?.sameTab && typeof window !== "undefined") {
window.location.assign(url)
return true
}
const popup = options?.popup
if (popup && !popup.closed) {
try {
popup.location.href = url
return true
} catch {
// fall through to general opener path
}
}
return await openExternalUrl(url, "provider-auth")
}
async function copyAuthorizationUrl(): Promise<void> {
const url = authorization()?.url
if (!url || typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
return
}
try {
await navigator.clipboard.writeText(url)
setAuthorizationLinkCopied(true)
setTimeout(() => setAuthorizationLinkCopied(false), 1500)
} catch {
setAuthorizationLinkCopied(false)
}
}
createEffect(() => {
if (!props.open) return
const authClient = client()
if (!authClient) return
void loadProviderData(authClient)
})
async function loadProviderData(authClient: OpencodeClient): Promise<void> {
setLoading(true)
setLoadError(null)
try {
const [providerListResponse, authResponse, configResponse] = await Promise.all([
(authClient as any).provider.list(),
(authClient as any).provider.auth(),
(authClient as any).config.get(),
])
const nextConfigData = (configResponse?.data ?? {}) as Record<string, any>
const nextConfiguredIds = new Set(Object.keys((nextConfigData.provider ?? {}) as Record<string, unknown>))
const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => ({
id: String(provider.id ?? ""),
name: String(provider.name ?? provider.id ?? ""),
modelCount: modelCountFromProvider(provider),
source:
provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api"
? provider.source
: "unknown",
})).filter((provider) => provider.id.length > 0)
setAvailableProviders(listed)
setConnectedProviderIds(new Set((providerListResponse?.data?.connected ?? []) as string[]))
setConfiguredProviderIds(nextConfiguredIds)
setConfigData(nextConfigData)
setMethodsByProvider((authResponse?.data ?? {}) as Record<string, ProviderAuthMethod[]>)
setSelectedProviderId((current) => current ?? listed[0]?.id ?? Object.keys(authResponse?.data ?? {})[0] ?? null)
} catch (error) {
setLoadError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.loadFailed")))
} finally {
setLoading(false)
}
}
function resetFlow(nextProviderId: string | null = null) {
callbackAbortController?.abort()
callbackAbortController = null
if (pendingOauthPopup && !pendingOauthPopup.closed) {
pendingOauthPopup.close()
}
pendingOauthPopup = null
setActiveProviderId(nextProviderId)
setSelectedMethodIndex(0)
setApiKey("")
setPromptValues({})
setAuthorization(null)
setCode("")
setStage(nextProviderId ? "prompts" : "idle")
setActionError(null)
setAuthorizationLaunchBlocked(false)
setAuthorizationLinkCopied(false)
}
function updatePromptValue(key: string, value: string) {
setPromptValues((current) => ({ ...current, [key]: value }))
}
async function refreshAfterAuth(authClient: OpencodeClient) {
await (authClient as any).global.dispose().catch(() => undefined)
await fetchProviders(props.instanceId).catch(() => undefined)
await loadProviderData(authClient).catch(() => undefined)
}
async function submitApiAuth(providerId: string, authClient: OpencodeClient) {
await requestData(
(authClient as any).auth.set({ providerID: providerId, auth: { type: "api", key: apiKey().trim() } }),
"auth.set",
)
await refreshAfterAuth(authClient)
resetFlow(null)
}
async function submitOAuthAuthorize(providerId: string, authClient: OpencodeClient) {
const response = await (authClient as any).provider.oauth.authorize(
{ providerID: providerId, method: selectedMethodIndex() },
{ throwOnError: true },
)
const data = response?.data as ProviderAuthAuthorization | undefined
if (!data) throw new Error(t("settings.providers.errors.noAuthorization"))
setAuthorization(data)
const opened = await launchAuthorizationUrl(data.url, { popup: pendingOauthPopup })
pendingOauthPopup = null
setAuthorizationLaunchBlocked(!opened)
if (data.method === "code") {
setStage("code")
return
}
setStage("waiting")
callbackAbortController = new AbortController()
await requestData(
(authClient as any).provider.oauth.callback(
{ providerID: providerId, method: selectedMethodIndex() },
{ signal: callbackAbortController.signal },
),
"provider.oauth.callback",
)
callbackAbortController = null
await refreshAfterAuth(authClient)
resetFlow(null)
}
async function submitAuth() {
const providerId = activeProviderId()
const authClient = client()
if (!providerId || !authClient || !canSubmit()) return
setStage("authorizing")
setActionError(null)
try {
if (selectedMethod().type === "api") {
await submitApiAuth(providerId, authClient)
return
}
pendingOauthPopup = prepareOAuthPopupWindow()
setAuthorizationLaunchBlocked(isBrowserHostForOAuth() && pendingOauthPopup === null)
await submitOAuthAuthorize(providerId, authClient)
} catch (error) {
if (pendingOauthPopup && !pendingOauthPopup.closed) {
pendingOauthPopup.close()
}
pendingOauthPopup = null
if (isAbortError(error)) {
setStage("prompts")
return
}
setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.authorizationFailed")))
setStage("error")
}
}
async function submitOAuthCode() {
const providerId = activeProviderId()
const authClient = client()
if (!providerId || !authClient || !code().trim()) return
setStage("authorizing")
setActionError(null)
try {
await requestData(
(authClient as any).provider.oauth.callback({ providerID: providerId, method: selectedMethodIndex(), code: code().trim() }),
"provider.oauth.callback",
)
await refreshAfterAuth(authClient)
resetFlow(null)
} catch (error) {
setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.authorizationFailed")))
setStage("code")
}
}
async function disconnectProvider(providerId: string) {
const authClient = client()
const provider = availableProviders().find((item) => item.id === providerId)
if (!authClient || !provider) return
setActionError(null)
setStage("authorizing")
try {
const disconnectMode = getDisconnectMode(provider)
if (disconnectMode === "not-disconnectable") {
setActionError(t("settings.providers.errors.envDisconnectUnavailable"))
setStage("error")
return
}
if (disconnectMode === "disable-in-config") {
const disabledProviders = Array.isArray(configData().disabled_providers)
? [...configData().disabled_providers]
: []
if (!disabledProviders.includes(providerId)) {
disabledProviders.push(providerId)
}
await requestData(
(authClient as any).config.update({
config: {
...configData(),
disabled_providers: disabledProviders,
},
}),
"config.update",
)
} else {
await requestData((authClient as any).auth.remove({ providerID: providerId }), "auth.remove")
}
await refreshAfterAuth(authClient)
resetFlow(null)
} catch (error) {
setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.removeFailed")))
setStage("error")
}
}
function cancelOAuthWait() {
callbackAbortController?.abort()
callbackAbortController = null
setStage("prompts")
setAuthorization(null)
setActionError(null)
}
function methodSummary(providerId: string) {
const methods = methodsByProvider()[providerId]
if (!methods || methods.length === 0) return t("settings.providers.method.fallback")
const kinds = new Set(methods.map((method) => method.type))
if (kinds.size > 1) return t("settings.providers.method.mixed")
if (kinds.has("oauth")) return t("settings.providers.method.oauth")
return t("settings.providers.method.api")
}
return (
<Dialog open={props.open} onOpenChange={handleModalOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<Dialog.Content class="modal-surface providers-manager-modal">
<div class="providers-manager-header">
<div class="settings-card-heading-with-icon">
<PlugZap class="settings-card-heading-icon" />
<div>
<Dialog.Title class="providers-manager-title">{t("settings.providers.title")}</Dialog.Title>
<p class="settings-card-subtitle">{t("settings.providers.subtitle")}</p>
</div>
</div>
<button type="button" class="selector-button selector-button-secondary settings-screen-close" onClick={() => handleModalOpenChange(false)} aria-label={t("settings.close")}>
<X class="w-4 h-4" />
</button>
</div>
<div class="providers-manager-body">
<Show when={!client()}>
<div class="settings-card-message">{t("settings.providers.empty.noInstance")}</div>
</Show>
<Show when={client()}>
<div class="providers-connect-bar">
<Select<ConfigurableProviderOption>
value={selectedProviderOption()}
onChange={(option) => option && setSelectedProviderId(option.id)}
options={configurableProviders()}
optionValue="id"
optionTextValue="name"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option selector-option--multiline">
<div class="selector-option-content">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Select.ItemLabel>
<div class="selector-option-description">
<span dir="ltr">{itemProps.item.rawValue.id}</span>
<span> </span>
<span>
{itemProps.item.rawValue.modelCount === 1
? t("settings.providers.models.one", { count: itemProps.item.rawValue.modelCount })
: t("settings.providers.models.other", { count: itemProps.item.rawValue.modelCount })}
</span>
<span> </span>
<span>{itemProps.item.rawValue.connectionSummary}</span>
</div>
</div>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger providers-connect-select" aria-label={t("settings.providers.selectProvider") }>
<div class="flex-1 min-w-0">
<Select.Value<ConfigurableProviderOption>>
{(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{state.selectedOption()?.name ?? t("settings.providers.selectProvider")}</span>
<Show when={state.selectedOption()}>
<span class="selector-trigger-secondary" dir="ltr">
{state.selectedOption()?.id} {state.selectedOption()?.connectionSummary}
</span>
</Show>
</div>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon"><ChevronDown class="w-3 h-3" /></Select.Icon>
</Select.Trigger>
<Select.Portal><Select.Content class="selector-popover"><Select.Listbox class="selector-listbox" /></Select.Content></Select.Portal>
</Select>
<button type="button" class="selector-button selector-button-primary" disabled={!selectedProviderOption()} onClick={() => resetFlow(selectedProviderOption()?.id ?? null)}>
{t("settings.providers.actions.connect")}
</button>
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => client() && void loadProviderData(client()!)}>
<RefreshCw class={loading() ? "providers-spin-icon" : "providers-button-icon"} />
{t("settings.providers.refresh")}
</button>
</div>
<Show when={loadError()}>
<div class="settings-error-message">{loadError()}</div>
</Show>
<Show when={activeProviderId()}>
<section class="providers-connect-panel">
<div class="providers-panel-header">
<div>
<h3 class="settings-card-title">{t("settings.providers.auth.title", { provider: activeProviderName() })}</h3>
<p class="settings-card-subtitle">{t("settings.providers.auth.subtitle")}</p>
</div>
<button
type="button"
class="selector-button selector-button-secondary settings-screen-close"
onClick={() => resetFlow(null)}
aria-label={t("settings.providers.actions.close")}
title={t("settings.providers.actions.close")}
>
<X class="w-4 h-4" />
</button>
</div>
<Show when={methodOptions().length > 1}>
<div class="settings-toggle-row settings-toggle-row-compact providers-method-row">
<div><div class="settings-toggle-title">{t("settings.providers.method.title")}</div><div class="settings-toggle-caption">{t("settings.providers.method.subtitle")}</div></div>
<Select<MethodOption>
value={selectedMethodOption()}
onChange={(option) => {
if (!option) return
setSelectedMethodIndex(option.index)
setPromptValues({})
setApiKey("")
setAuthorization(null)
setCode("")
setStage("prompts")
setActionError(null)
}}
options={methodOptions()}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => <Select.Item item={itemProps.item} class="selector-option"><Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel></Select.Item>}
>
<Select.Trigger class="selector-trigger providers-method-trigger" aria-label={t("settings.providers.method.title")}>
<div class="flex-1 min-w-0"><Select.Value<MethodOption>>{(state) => <span class="selector-trigger-primary selector-trigger-primary--align-left">{state.selectedOption()?.label}</span>}</Select.Value></div>
<Select.Icon class="selector-trigger-icon"><ChevronDown class="w-3 h-3" /></Select.Icon>
</Select.Trigger>
<Select.Portal><Select.Content class="selector-popover"><Select.Listbox class="selector-listbox" /></Select.Content></Select.Portal>
</Select>
</div>
</Show>
<Show when={selectedMethod().type === "api"}>
<div class="providers-form-stack"><label class="providers-field"><span class="settings-form-label">{t("settings.providers.apiKey.label")}</span><div class="providers-input-wrap"><KeyRound class="providers-input-icon" /><input type="password" class="providers-input" value={apiKey()} onInput={(event) => setApiKey(event.currentTarget.value)} placeholder={t("settings.providers.apiKey.placeholder")} autocomplete="off" /></div></label></div>
</Show>
<Show when={selectedMethod().type === "oauth" && (stage() === "prompts" || stage() === "error" || stage() === "authorizing")}>
<div class="providers-form-stack">
<Show when={oauthPromptsUnsupported()}>
<div class="settings-error-message">{t("settings.providers.oauth.promptsUnsupported")}</div>
</Show>
<Show when={visiblePrompts().length === 0}><div class="settings-card-message">{t("settings.providers.oauth.noPrompts")}</div></Show>
<For each={oauthPromptsUnsupported() ? [] : visiblePrompts()}>{(prompt) => (
<div class="providers-field">
<label class="settings-form-label" for={`provider-prompt-${prompt.key}`}>{prompt.message}</label>
<Show when={prompt.type === "select"} fallback={<input id={`provider-prompt-${prompt.key}`} type="text" class="providers-input" value={promptValues()[prompt.key] ?? ""} onInput={(event) => updatePromptValue(prompt.key, event.currentTarget.value)} placeholder={prompt.type === "text" ? prompt.placeholder : undefined} />}>
<Select<{ label: string; value: string; hint?: string }> value={(prompt.type === "select" ? prompt.options : []).find((option) => option.value === promptValues()[prompt.key])} onChange={(option) => option && updatePromptValue(prompt.key, option.value)} options={prompt.type === "select" ? prompt.options : []} optionValue="value" optionTextValue="label" itemComponent={(itemProps) => <Select.Item item={itemProps.item} class="selector-option"><Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}<Show when={itemProps.item.rawValue.hint}><span class="providers-select-hint">{itemProps.item.rawValue.hint}</span></Show></Select.ItemLabel></Select.Item>}>
<Select.Trigger class="selector-trigger providers-prompt-trigger" aria-label={prompt.message}><div class="flex-1 min-w-0"><Select.Value<{ label: string; value: string; hint?: string }>>{(state) => <span class="selector-trigger-primary selector-trigger-primary--align-left">{state.selectedOption()?.label ?? t("settings.providers.prompt.selectPlaceholder")}</span>}</Select.Value></div><Select.Icon class="selector-trigger-icon"><ChevronDown class="w-3 h-3" /></Select.Icon></Select.Trigger>
<Select.Portal><Select.Content class="selector-popover"><Select.Listbox class="selector-listbox" /></Select.Content></Select.Portal>
</Select>
</Show>
</div>
)}</For>
</div>
</Show>
<Show when={stage() === "code"}><div class="providers-form-stack"><div class="providers-oauth-instructions"><ExternalLink class="providers-instructions-icon" /><span>{authorization()?.instructions || t("settings.providers.oauth.enterCode")}</span></div><label class="providers-field"><span class="settings-form-label">{t("settings.providers.oauth.codeLabel")}</span><input type="text" class="providers-input" value={code()} onInput={(event) => setCode(event.currentTarget.value)} placeholder={t("settings.providers.oauth.codePlaceholder")} autocomplete="one-time-code" /></label></div></Show>
<Show when={stage() === "waiting"}><div class="providers-waiting-card"><Loader2 class="providers-spin-icon" /><div><div class="settings-toggle-title">{t("settings.providers.oauth.waitingTitle")}</div><div class="settings-toggle-caption">{authorization()?.instructions}</div></div><button type="button" class="selector-button selector-button-secondary providers-wait-cancel" onClick={cancelOAuthWait}>{t("settings.providers.oauth.cancelWait")}</button></div></Show>
<Show when={authorization() && (stage() === "code" || stage() === "waiting")}>
<div class="providers-oauth-actions">
<a href={authorization()?.url} target="_blank" rel="noopener noreferrer" class="selector-button selector-button-secondary providers-oauth-link">
<ExternalLink class="w-4 h-4" />
{t("settings.providers.oauth.openPage")}
</a>
<button type="button" class="selector-button selector-button-secondary" onClick={() => void launchAuthorizationUrl(authorization()!.url, { sameTab: true })}>
{t("settings.providers.oauth.openHere")}
</button>
<button type="button" class="selector-button selector-button-secondary" onClick={() => void copyAuthorizationUrl()}>
{authorizationLinkCopied() ? t("settings.providers.oauth.linkCopied") : t("settings.providers.oauth.copyLink")}
</button>
</div>
</Show>
<Show when={authorizationLaunchBlocked() && authorization()}>
<div class="settings-card-message">{t("settings.providers.oauth.popupBlocked")}</div>
</Show>
<Show when={stage() === "success"}><div class="providers-success-card"><Check class="providers-success-icon" /><span>{t("settings.providers.success")}</span></div></Show>
<Show when={actionError()}><div class="settings-error-message">{actionError()}</div></Show>
<div class="providers-actions-row">
<Show when={stage() === "code"} fallback={<button type="button" class="selector-button selector-button-primary" disabled={!canSubmit()} onClick={() => void submitAuth()}><Show when={stage() === "authorizing"} fallback={t("settings.providers.actions.continue")}><Loader2 class="providers-spin-icon" />{t("settings.providers.actions.working")}</Show></button>}>
<button type="button" class="selector-button selector-button-primary" disabled={!code().trim()} onClick={() => void submitOAuthCode()}>{t("settings.providers.oauth.submitCode")}</button>
</Show>
</div>
</section>
</Show>
<section class="providers-list-section">
<h3 class="settings-card-title">{t("settings.providers.configured.title")}</h3>
<Show when={loading()}><div class="providers-loading-row"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
<div class="providers-grid">
<For each={configuredProviders()}>{(provider) => (
<article class="providers-card">
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
<div class="providers-card-footer"><span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span><Show when={getDisconnectMode(provider) !== "disable-in-config"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={getDisconnectMode(provider) === "not-disconnectable"} onClick={() => void disconnectProvider(provider.id)} title={getDisconnectMode(provider) === "not-disconnectable" ? t("settings.providers.source.env") : t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show></div>
</article>
)}</For>
</div>
</section>
</Show>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}

View file

@ -1,23 +1,25 @@
import { isTauriHost } from "./runtime-env"
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
export async function openExternalUrl(url: string, context = "ui"): Promise<boolean> {
if (typeof window === "undefined") {
return
return false
}
if (isTauriHost()) {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
return
return true
} catch (error) {
console.warn(`[${context}] unable to open via system opener`, error)
}
}
try {
window.open(url, "_blank", "noopener,noreferrer")
const popup = window.open(url, "_blank", "noopener,noreferrer")
return popup !== null
} catch (error) {
console.warn(`[${context}] unable to open external url`, error)
return false
}
}

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Show all models",
"modelSelector.favorite.add": "Add to favorites",
"modelSelector.favorite.remove": "Remove from favorites",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "Default",
"thinkingSelector.label": "Thinking: {variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "Warn",
"settings.opencode.logLevel.option.error": "Error",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
"modelSelector.favorite.add": "Agregar a favoritos",
"modelSelector.favorite.remove": "Quitar de favoritos",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "Por defecto",
"thinkingSelector.label": "Pensamiento: {variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "Advertencia",
"settings.opencode.logLevel.option.error": "Error",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
"modelSelector.favorite.add": "Ajouter aux favoris",
"modelSelector.favorite.remove": "Retirer des favoris",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "Par défaut",
"thinkingSelector.label": "Réflexion : {variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "Avertissement",
"settings.opencode.logLevel.option.error": "Erreur",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
"modelSelector.favorite.add": "הוסף למועדפים",
"modelSelector.favorite.remove": "הסר ממועדפים",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "ברירת מחדל",
"thinkingSelector.label": "חשיבה: {variant}",
@ -69,6 +70,7 @@ export const settingsMessages = {
"settings.nav.appearance": "מראה",
"settings.nav.notifications": "התראות",
"settings.nav.remote": "גישה מרוחקת",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "מכשיר זה",
@ -126,6 +128,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "אזהרה",
"settings.opencode.logLevel.option.error": "שגיאה",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
"settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
"modelSelector.favorite.add": "お気に入りに追加",
"modelSelector.favorite.remove": "お気に入りから削除",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "デフォルト",
"thinkingSelector.label": "思考: {variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "エラー",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "Показать все модели",
"modelSelector.favorite.add": "Добавить в избранное",
"modelSelector.favorite.remove": "Удалить из избранного",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "По умолчанию",
"thinkingSelector.label": "Размышления: {variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "Предупреждение",
"settings.opencode.logLevel.option.error": "Ошибка",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
"settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш",

View file

@ -32,6 +32,7 @@ export const settingsMessages = {
"modelSelector.favoritesOnly.showAll": "显示所有模型",
"modelSelector.favorite.add": "添加到收藏",
"modelSelector.favorite.remove": "从收藏移除",
"modelSelector.manageProviders": "Manage Providers",
"thinkingSelector.variant.default": "默认",
"thinkingSelector.label": "思考:{variant}",
@ -70,6 +71,7 @@ export const settingsMessages = {
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.providers": "Providers",
"settings.nav.opencode": "OpenCode",
"settings.nav.info": "Info",
"settings.scope.device": "This device",
@ -127,6 +129,63 @@ export const settingsMessages = {
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "错误",
"settings.providers.title": "Provider Authentication",
"settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.",
"settings.providers.refresh": "Refresh",
"settings.providers.selectProvider": "Select provider",
"settings.providers.available.title": "Available Providers",
"settings.providers.configured.title": "Configured Providers",
"settings.providers.loading": "Loading provider authentication methods...",
"settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.",
"settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.",
"settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.",
"settings.providers.models.one": "{count} model",
"settings.providers.models.other": "{count} models",
"settings.providers.actions.connect": "Connect",
"settings.providers.actions.disconnect": "Disconnect",
"settings.providers.actions.remove": "Remove saved auth",
"settings.providers.actions.close": "Close",
"settings.providers.actions.continue": "Continue",
"settings.providers.actions.working": "Working...",
"settings.providers.method.title": "Authentication method",
"settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.",
"settings.providers.method.api": "API key",
"settings.providers.method.oauth": "OAuth",
"settings.providers.method.mixed": "API key and OAuth",
"settings.providers.method.fallback": "API key",
"settings.providers.status.available": "Available",
"settings.providers.auth.title": "Connect {provider}",
"settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.",
"settings.providers.apiKey.label": "API key",
"settings.providers.apiKey.placeholder": "Paste provider API key",
"settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.",
"settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.",
"settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.",
"settings.providers.oauth.openPage": "Open authorization page",
"settings.providers.oauth.openHere": "Open in this tab",
"settings.providers.oauth.copyLink": "Copy link",
"settings.providers.oauth.linkCopied": "Link copied",
"settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.",
"settings.providers.oauth.popup.loadingTitle": "OpenCode authorization",
"settings.providers.oauth.popup.loadingBody": "Preparing authorization page...",
"settings.providers.oauth.codeLabel": "Authorization code",
"settings.providers.oauth.codePlaceholder": "Paste code from provider",
"settings.providers.oauth.submitCode": "Submit code",
"settings.providers.oauth.waitingTitle": "Waiting for authorization",
"settings.providers.oauth.cancelWait": "Cancel",
"settings.providers.prompt.selectPlaceholder": "Select an option",
"settings.providers.success": "Provider authentication saved. Provider data has been refreshed.",
"settings.providers.source.api": "Saved provider authentication",
"settings.providers.source.config": "Configured in opencode.jsonc",
"settings.providers.source.custom": "Configured by custom provider logic",
"settings.providers.source.env": "Configured from environment variables",
"settings.providers.source.unknown": "Configured provider source",
"settings.providers.errors.loadFailed": "Failed to load provider authentication methods.",
"settings.providers.errors.noAuthorization": "Provider did not return authorization data.",
"settings.providers.errors.authorizationFailed": "Authorization failed.",
"settings.providers.errors.removeFailed": "Failed to remove provider authentication.",
"settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.",
"settings.appearance.behavior.title": "交互",
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
"settings.behavior.keyboardHints.title": "键盘快捷键提示",

View file

@ -0,0 +1,58 @@
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
prompts?: ProviderAuthPrompt[]
}
export type ProviderAuthPrompt =
| {
type: "text"
key: string
message: string
placeholder?: string
when?: ProviderAuthPromptCondition
}
| {
type: "select"
key: string
message: string
options: Array<{ label: string; value: string; hint?: string }>
when?: ProviderAuthPromptCondition
}
export type ProviderAuthPromptCondition = {
key: string
op: "eq" | "neq"
value: string
}
export type ProviderAuthAuthorization = {
url: string
method: "auto" | "code"
instructions: string
}
export const genericApiMethod: ProviderAuthMethod = { type: "api", label: "" }
export function extractProviderAuthErrorMessage(error: unknown, fallback: string): string {
const candidate = error as {
data?: { message?: unknown }
message?: unknown
error?: { data?: { message?: unknown }; message?: unknown }
}
const nested = candidate?.error
const message = candidate?.data?.message ?? nested?.data?.message ?? candidate?.message ?? nested?.message
return typeof message === "string" && message.trim().length > 0 ? message : fallback
}
export function shouldShowProviderAuthPrompt(prompt: ProviderAuthPrompt, values: Record<string, string>): boolean {
if (!prompt.when) return true
const actual = values[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
export function isAbortError(error: unknown): boolean {
const candidate = error as { name?: unknown; message?: unknown }
return candidate?.name === "AbortError" || candidate?.message === "This operation was aborted"
}

View file

@ -1,6 +1,13 @@
import { createSignal } from "solid-js"
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars" | "info"
export type SettingsSectionId =
| "appearance"
| "notifications"
| "remote"
| "speech"
| "opencode"
| "sidecars"
| "info"
const [settingsOpen, setSettingsOpen] = createSignal(false)
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")

View file

@ -0,0 +1,387 @@
.providers-manager-modal {
position: fixed;
left: 50%;
top: 50%;
z-index: 60;
transform: translate(-50%, -50%);
width: min(880px, calc(100vw - 2rem));
max-height: min(860px, calc(100dvh - 2rem));
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0;
}
.providers-manager-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid var(--border-base);
}
.providers-manager-title {
color: var(--text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: 1.25;
}
.providers-manager-body {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
overflow-y: auto;
padding: 1rem;
}
.providers-connect-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.875rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: color-mix(in oklab, var(--surface-base) 84%, var(--surface-secondary) 16%);
}
.providers-connect-select {
width: 100%;
}
.providers-connect-panel,
.providers-list-section {
padding: 1rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: color-mix(in oklab, var(--surface-base) 88%, var(--surface-secondary) 12%);
}
.providers-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
}
.providers-overview-card,
.providers-auth-card {
position: relative;
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.875rem;
}
.providers-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 13rem;
padding: 1rem;
border: 1px solid var(--border-base);
border-radius: 0;
background:
linear-gradient(135deg, color-mix(in oklab, var(--surface-base) 84%, var(--accent-primary) 5%), var(--surface-base));
transition: border-color 140ms ease, background-color 140ms ease, transform 140ms ease;
}
.providers-card:hover,
.providers-card[data-active="true"] {
border-color: color-mix(in oklab, var(--accent-primary) 34%, var(--border-base));
background: color-mix(in oklab, var(--accent-primary) 8%, var(--surface-base));
}
.providers-card[data-active="true"] {
transform: translateY(-1px);
}
.providers-card-main {
display: flex;
gap: 0.875rem;
min-width: 0;
}
.providers-card-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.35rem;
height: 2.35rem;
flex-shrink: 0;
border: 1px solid color-mix(in oklab, var(--accent-primary) 22%, var(--border-base));
border-radius: 0;
background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base));
color: var(--accent-primary);
}
.providers-card-mark-icon,
.providers-button-icon,
.providers-input-icon,
.providers-instructions-icon,
.providers-success-icon {
width: 1rem;
height: 1rem;
}
.providers-card-copy {
min-width: 0;
}
.providers-card-title {
color: var(--text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
line-height: 1.25;
}
.providers-card-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.providers-connected-badge {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 0.2rem 0.4rem;
border: 1px solid color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
border-radius: 0;
background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base));
color: var(--accent-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.providers-card-meta,
.providers-card-methods,
.providers-model-count,
.providers-select-hint {
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.providers-card-source {
margin-top: 0.35rem;
color: var(--text-secondary);
font-size: var(--font-size-xs);
}
.providers-card-meta {
margin-top: 0.2rem;
overflow-wrap: anywhere;
}
.providers-card-methods {
margin-top: 0.75rem;
line-height: 1.4;
}
.providers-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1.25rem;
padding-top: 0.875rem;
border-top: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
}
.providers-disconnect-button {
width: min(9rem, 50%);
min-width: 6.5rem;
justify-content: center;
padding-inline: 0.75rem;
}
.providers-loading-row,
.providers-waiting-card,
.providers-success-card,
.providers-oauth-instructions {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: var(--surface-base);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.providers-oauth-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.providers-oauth-link {
text-decoration: none;
}
.providers-waiting-card {
align-items: flex-start;
}
.providers-wait-cancel {
margin-inline-start: auto;
flex-shrink: 0;
width: auto;
}
.providers-success-card {
border-color: color-mix(in oklab, var(--accent-primary) 34%, var(--border-base));
background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base));
color: var(--text-primary);
}
.providers-success-icon {
color: var(--accent-primary);
}
.providers-form-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.providers-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.providers-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.providers-input-icon {
position: absolute;
inset-inline-start: 0.75rem;
color: var(--text-muted);
pointer-events: none;
}
.providers-input {
width: 100%;
min-width: 0;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: var(--surface-base);
color: var(--text-primary);
font-size: var(--font-size-sm);
outline: none;
}
.providers-input-wrap .providers-input {
padding-inline-start: 2.25rem;
}
.providers-input:focus {
border-color: color-mix(in oklab, var(--accent-primary) 48%, var(--border-base));
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 16%, transparent);
}
.providers-method-row {
margin-bottom: 1rem;
padding-top: 0;
border-top: none;
}
.providers-method-trigger,
.providers-prompt-trigger {
min-width: min(22rem, 100%);
}
.providers-select-hint {
margin-inline-start: 0.5rem;
}
.providers-actions-row {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
}
.providers-spin-icon {
width: 1rem;
height: 1rem;
animation: providers-spin 900ms linear infinite;
}
@keyframes providers-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.providers-manager-modal {
width: 100vw;
max-height: 100dvh;
height: 100dvh;
}
.providers-connect-bar,
.providers-panel-header {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.providers-connect-bar .selector-button,
.providers-connect-bar .settings-pill-button {
width: 100%;
justify-content: center;
}
.providers-grid {
grid-template-columns: 1fr;
}
.providers-card-footer,
.providers-actions-row {
align-items: stretch;
flex-direction: column;
}
.providers-oauth-actions {
flex-direction: column;
}
.providers-waiting-card {
flex-direction: column;
}
.providers-wait-cancel {
margin-inline-start: 0;
align-self: flex-start;
}
.providers-card-footer .selector-button,
.providers-actions-row .selector-button,
.providers-method-trigger,
.providers-prompt-trigger {
width: 100%;
}
.providers-disconnect-button {
min-width: 0;
}
}

View file

@ -11,3 +11,4 @@
@import "./components/permission-notification.css";
@import "./components/settings-screen.css";
@import "./components/settings-info.css";
@import "./components/provider-auth.css";