feat(tui): show console-managed providers (#20956)

This commit is contained in:
Kit Langton
2026-04-04 19:05:45 -04:00
committed by GitHub
parent 280eb16e77
commit 6ea108a03b
15 changed files with 707 additions and 123 deletions

View File

@@ -36,6 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -629,6 +630,19 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "Provider",
},
{
title: "Switch org",
value: "console.org.switch",
suggested: Boolean(sync.data.console_state.activeOrgName),
slash: {
name: "org",
aliases: ["orgs", "switch-org"],
},
onSelect: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
{
title: "View status",
keybind: "status_view",

View File

@@ -0,0 +1,103 @@
import { createResource, createMemo } from "solid-js"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { useTheme } from "@tui/context/theme"
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
const accountHost = (url: string) => {
try {
return new URL(url).host
} catch {
return url
}
}
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
`${item.accountEmail} ${accountHost(item.accountUrl)}`
export function DialogConsoleOrg() {
const sdk = useSDK()
const dialog = useDialog()
const toast = useToast()
const { theme } = useTheme()
const [orgs] = createResource(async () => {
const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
return result.data?.orgs ?? []
})
const current = createMemo(() => orgs()?.find((item) => item.active))
const options = createMemo(() => {
const listed = orgs()
if (listed === undefined) {
return [
{
title: "Loading orgs...",
value: "loading",
onSelect: () => {},
},
]
}
if (listed.length === 0) {
return [
{
title: "No orgs found",
value: "empty",
onSelect: () => {},
},
]
}
return listed
.toSorted((a, b) => {
const activeAccountA = a.active ? 0 : 1
const activeAccountB = b.active ? 0 : 1
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
if (accountCompare !== 0) return accountCompare
return a.orgName.localeCompare(b.orgName)
})
.map((item) => ({
title: item.orgName,
value: item,
category: accountLabel(item),
categoryView: (
<box flexDirection="row" gap={2}>
<text fg={theme.accent}>{item.accountEmail}</text>
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
</box>
),
onSelect: async () => {
if (item.active) {
dialog.clear()
return
}
await sdk.client.experimental.console.switchOrg(
{
accountID: item.accountID,
orgID: item.orgID,
},
{ throwOnError: true },
)
await sdk.client.instance.dispose()
toast.show({
message: `Switched to ${item.orgName}`,
variant: "info",
})
dialog.clear()
},
}))
})
return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
}

View File

@@ -8,6 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() {
const sync = useSync()
@@ -46,7 +47,11 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -84,7 +89,9 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
@@ -132,7 +139,11 @@ export function DialogModel(props: { providerID?: string }) {
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
)
const title = createMemo(() => provider()?.name ?? "Select model")
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
})
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })

View File

@@ -13,6 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -28,87 +29,111 @@ export function createDialogProviderOptions() {
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: consoleManaged ? (
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[provider.id] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options

View File

@@ -35,6 +35,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = {
sessionID?: string
@@ -94,6 +95,14 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
const currentProviderLabel = createMemo(() => {
const current = local.model.current()
const provider = local.model.parsed().provider
if (!current) return provider
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
})
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
function promptModelWarning() {
toast.show({
@@ -1095,7 +1104,7 @@ export function Prompt(props: PromptProps) {
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
@@ -1105,7 +1114,16 @@ export function Prompt(props: PromptProps) {
</box>
</Show>
</box>
{props.right}
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={activeOrgName()}>
<text fg={theme.textMuted} onMouseUp={() => command.trigger("console.org.switch")}>
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
</text>
</Show>
</box>
</Show>
</box>
</box>
</box>

View File

@@ -29,6 +29,7 @@ import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -38,6 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleStateType
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@@ -81,6 +83,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
default: {},
connected: [],
},
console_state: emptyConsoleState,
provider_auth: {},
config: {},
status: "loading",
@@ -365,6 +368,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [
@@ -379,6 +386,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then(() => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
@@ -386,20 +394,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return Promise.all([
providersResponse,
providerListResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const agents = responses[2]
const config = responses[3]
const sessions = responses[4]
const consoleState = responses[2]
const agents = responses[3]
const config = responses[4]
const sessions = responses[5]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
@@ -411,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// non-blocking
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),

View File

@@ -38,6 +38,7 @@ export interface DialogSelectOption<T = any> {
description?: string
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
@@ -291,9 +292,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
<Show
when={options[0]?.categoryView}
fallback={
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
}
>
{options[0]?.categoryView}
</Show>
</box>
</Show>
<For each={options}>

View File

@@ -0,0 +1,20 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
: consoleManagedProviders.has(providerID)
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)
export const consoleManagedProviderSuffix = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
export const consoleManagedProviderLabel = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
providerName: string,
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`