Skip to content

Commit 6ea108a

Browse files
authored
feat(tui): show console-managed providers (anomalyco#20956)
1 parent 280eb16 commit 6ea108a

File tree

15 files changed

+706
-122
lines changed

15 files changed

+706
-122
lines changed

packages/opencode/src/account/index.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export type AccountOrgs = {
5252
orgs: readonly Org[]
5353
}
5454

55+
export type ActiveOrg = {
56+
account: Info
57+
org: Org
58+
}
59+
5560
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
5661
config: Schema.Record(Schema.String, Schema.Json),
5762
}) {}
@@ -137,6 +142,7 @@ const mapAccountServiceError =
137142
export namespace Account {
138143
export interface Interface {
139144
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
145+
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
140146
readonly list: () => Effect.Effect<Info[], AccountError>
141147
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
142148
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
@@ -279,19 +285,31 @@ export namespace Account {
279285
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
280286
)
281287

288+
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
289+
const activeAccount = yield* repo.active()
290+
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
291+
292+
const account = activeAccount.value
293+
if (!account.active_org_id) return Option.none<ActiveOrg>()
294+
295+
const accountOrgs = yield* orgs(account.id)
296+
const org = accountOrgs.find((item) => item.id === account.active_org_id)
297+
if (!org) return Option.none<ActiveOrg>()
298+
299+
return Option.some({ account, org })
300+
})
301+
282302
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
283303
const accounts = yield* repo.list()
284-
const [errors, results] = yield* Effect.partition(
304+
return yield* Effect.forEach(
285305
accounts,
286-
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
306+
(account) =>
307+
orgs(account.id).pipe(
308+
Effect.catch(() => Effect.succeed([] as readonly Org[])),
309+
Effect.map((orgs) => ({ account, orgs })),
310+
),
287311
{ concurrency: 3 },
288312
)
289-
for (const error of errors) {
290-
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
291-
Effect.annotateLogs({ error: String(error) }),
292-
)
293-
}
294-
return results
295313
})
296314

297315
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
@@ -396,6 +414,7 @@ export namespace Account {
396414

397415
return Service.of({
398416
active: repo.active,
417+
activeOrg,
399418
list: repo.list,
400419
orgsByAccount,
401420
remove: repo.remove,
@@ -417,6 +436,26 @@ export namespace Account {
417436
return Option.getOrUndefined(await runPromise((service) => service.active()))
418437
}
419438

439+
export async function list(): Promise<Info[]> {
440+
return runPromise((service) => service.list())
441+
}
442+
443+
export async function activeOrg(): Promise<ActiveOrg | undefined> {
444+
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
445+
}
446+
447+
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
448+
return runPromise((service) => service.orgsByAccount())
449+
}
450+
451+
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
452+
return runPromise((service) => service.orgs(accountID))
453+
}
454+
455+
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
456+
return runPromise((service) => service.use(accountID, Option.some(orgID)))
457+
}
458+
420459
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
421460
const t = await runPromise((service) => service.token(accountID))
422461
return Option.getOrUndefined(t)

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
3636
import { DialogAgent } from "@tui/component/dialog-agent"
3737
import { DialogSessionList } from "@tui/component/dialog-session-list"
3838
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
39+
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
3940
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
4041
import { ThemeProvider, useTheme } from "@tui/context/theme"
4142
import { Home } from "@tui/routes/home"
@@ -629,6 +630,19 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
629630
},
630631
category: "Provider",
631632
},
633+
{
634+
title: "Switch org",
635+
value: "console.org.switch",
636+
suggested: Boolean(sync.data.console_state.activeOrgName),
637+
slash: {
638+
name: "org",
639+
aliases: ["orgs", "switch-org"],
640+
},
641+
onSelect: () => {
642+
dialog.replace(() => <DialogConsoleOrg />)
643+
},
644+
category: "Provider",
645+
},
632646
{
633647
title: "View status",
634648
keybind: "status_view",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createResource, createMemo } from "solid-js"
2+
import { DialogSelect } from "@tui/ui/dialog-select"
3+
import { useSDK } from "@tui/context/sdk"
4+
import { useDialog } from "@tui/ui/dialog"
5+
import { useToast } from "@tui/ui/toast"
6+
import { useTheme } from "@tui/context/theme"
7+
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
8+
9+
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
10+
11+
const accountHost = (url: string) => {
12+
try {
13+
return new URL(url).host
14+
} catch {
15+
return url
16+
}
17+
}
18+
19+
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
20+
`${item.accountEmail} ${accountHost(item.accountUrl)}`
21+
22+
export function DialogConsoleOrg() {
23+
const sdk = useSDK()
24+
const dialog = useDialog()
25+
const toast = useToast()
26+
const { theme } = useTheme()
27+
28+
const [orgs] = createResource(async () => {
29+
const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
30+
return result.data?.orgs ?? []
31+
})
32+
33+
const current = createMemo(() => orgs()?.find((item) => item.active))
34+
35+
const options = createMemo(() => {
36+
const listed = orgs()
37+
if (listed === undefined) {
38+
return [
39+
{
40+
title: "Loading orgs...",
41+
value: "loading",
42+
onSelect: () => {},
43+
},
44+
]
45+
}
46+
47+
if (listed.length === 0) {
48+
return [
49+
{
50+
title: "No orgs found",
51+
value: "empty",
52+
onSelect: () => {},
53+
},
54+
]
55+
}
56+
57+
return listed
58+
.toSorted((a, b) => {
59+
const activeAccountA = a.active ? 0 : 1
60+
const activeAccountB = b.active ? 0 : 1
61+
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
62+
63+
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
64+
if (accountCompare !== 0) return accountCompare
65+
66+
return a.orgName.localeCompare(b.orgName)
67+
})
68+
.map((item) => ({
69+
title: item.orgName,
70+
value: item,
71+
category: accountLabel(item),
72+
categoryView: (
73+
<box flexDirection="row" gap={2}>
74+
<text fg={theme.accent}>{item.accountEmail}</text>
75+
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
76+
</box>
77+
),
78+
onSelect: async () => {
79+
if (item.active) {
80+
dialog.clear()
81+
return
82+
}
83+
84+
await sdk.client.experimental.console.switchOrg(
85+
{
86+
accountID: item.accountID,
87+
orgID: item.orgID,
88+
},
89+
{ throwOnError: true },
90+
)
91+
92+
await sdk.client.instance.dispose()
93+
toast.show({
94+
message: `Switched to ${item.orgName}`,
95+
variant: "info",
96+
})
97+
dialog.clear()
98+
},
99+
}))
100+
})
101+
102+
return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
103+
}

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
88
import { DialogVariant } from "./dialog-variant"
99
import { useKeybind } from "../context/keybind"
1010
import * as fuzzysort from "fuzzysort"
11+
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
1112

1213
export function useConnected() {
1314
const sync = useSync()
@@ -46,7 +47,11 @@ export function DialogModel(props: { providerID?: string }) {
4647
key: item,
4748
value: { providerID: provider.id, modelID: model.id },
4849
title: model.name ?? item.modelID,
49-
description: provider.name,
50+
description: consoleManagedProviderLabel(
51+
sync.data.console_state.consoleManagedProviders,
52+
provider.id,
53+
provider.name,
54+
),
5055
category,
5156
disabled: provider.id === "opencode" && model.id.includes("-nano"),
5257
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -84,7 +89,9 @@ export function DialogModel(props: { providerID?: string }) {
8489
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
8590
? "(Favorite)"
8691
: undefined,
87-
category: connected() ? provider.name : undefined,
92+
category: connected()
93+
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
94+
: undefined,
8895
disabled: provider.id === "opencode" && model.includes("-nano"),
8996
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
9097
onSelect() {
@@ -132,7 +139,11 @@ export function DialogModel(props: { providerID?: string }) {
132139
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
133140
)
134141

135-
const title = createMemo(() => provider()?.name ?? "Select model")
142+
const title = createMemo(() => {
143+
const value = provider()
144+
if (!value) return "Select model"
145+
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
146+
})
136147

137148
function onSelect(providerID: string, modelID: string) {
138149
local.model.set({ providerID, modelID }, { recent: true })

0 commit comments

Comments
 (0)