ソースを参照

feat(tui): switch console orgs in app

Kit Langton 3 週間 前
コミット
e972353d59

+ 8 - 0
packages/opencode/src/account/index.ts

@@ -442,6 +442,14 @@ export namespace Account {
     return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
   }
 
+  export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
+    return runPromise((service) => service.orgsByAccount())
+  }
+
+  export async function switchOrg(accountID: AccountID, orgID: OrgID) {
+    return runPromise((service) => service.use(accountID, Option.some(orgID)))
+  }
+
   export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
     const t = await runPromise((service) => service.token(accountID))
     return Option.getOrUndefined(t)

+ 14 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -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",

+ 90 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-console-org.tsx

@@ -0,0 +1,90 @@
+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"
+
+type OrgOption = {
+  accountID: string
+  accountEmail: string
+  accountUrl: string
+  orgID: string
+  orgName: string
+  active: boolean
+}
+
+export function DialogConsoleOrg() {
+  const sdk = useSDK()
+  const dialog = useDialog()
+  const toast = useToast()
+
+  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) => {
+        if (a.active !== b.active) return a.active ? -1 : 1
+        return a.orgName.localeCompare(b.orgName)
+      })
+      .map((item) => ({
+        title: item.orgName,
+        value: item,
+        description: `${item.accountEmail} · ${(() => {
+          try {
+            return new URL(item.accountUrl).host
+          } catch {
+            return item.accountUrl
+          }
+        })()}`,
+        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()} />
+}

+ 3 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -1118,7 +1118,9 @@ export function Prompt(props: PromptProps) {
                 <box flexDirection="row" gap={1} alignItems="center">
                   {props.right}
                   <Show when={activeOrgName()}>
-                    <text fg={theme.textMuted}>{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}</text>
+                    <text fg={theme.textMuted} onMouseUp={() => command.trigger("console.org.switch")}>
+                      {`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
+                    </text>
                   </Show>
                 </box>
               </Show>

+ 75 - 0
packages/opencode/src/server/routes/experimental.ts

@@ -10,11 +10,30 @@ import { MCP } from "../../mcp"
 import { Session } from "../../session"
 import { Config } from "../../config/config"
 import { ConsoleState } from "../../config/console-state"
+import { Account, AccountID, OrgID } from "../../account"
 import { zodToJsonSchema } from "zod-to-json-schema"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
 import { WorkspaceRoutes } from "./workspace"
 
+const ConsoleOrgOption = z.object({
+  accountID: z.string(),
+  accountEmail: z.string(),
+  accountUrl: z.string(),
+  orgID: z.string(),
+  orgName: z.string(),
+  active: z.boolean(),
+})
+
+const ConsoleOrgList = z.object({
+  orgs: z.array(ConsoleOrgOption),
+})
+
+const ConsoleSwitchBody = z.object({
+  accountID: z.string(),
+  orgID: z.string(),
+})
+
 export const ExperimentalRoutes = lazy(() =>
   new Hono()
     .get(
@@ -38,6 +57,62 @@ export const ExperimentalRoutes = lazy(() =>
         return c.json(await Config.getConsoleState())
       },
     )
+    .get(
+      "/console/orgs",
+      describeRoute({
+        summary: "List switchable Console orgs",
+        description: "Get the available Console orgs across logged-in accounts, including the current active org.",
+        operationId: "experimental.console.listOrgs",
+        responses: {
+          200: {
+            description: "Switchable Console orgs",
+            content: {
+              "application/json": {
+                schema: resolver(ConsoleOrgList),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
+        const orgs = groups.flatMap((group) =>
+          group.orgs.map((org) => ({
+            accountID: group.account.id,
+            accountEmail: group.account.email,
+            accountUrl: group.account.url,
+            orgID: org.id,
+            orgName: org.name,
+            active: !!active && active.id === group.account.id && active.active_org_id === org.id,
+          })),
+        )
+        return c.json({ orgs })
+      },
+    )
+    .post(
+      "/console/switch",
+      describeRoute({
+        summary: "Switch active Console org",
+        description: "Persist a new active Console account/org selection for the current local OpenCode state.",
+        operationId: "experimental.console.switchOrg",
+        responses: {
+          200: {
+            description: "Switch success",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      validator("json", ConsoleSwitchBody),
+      async (c) => {
+        const body = c.req.valid("json")
+        await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
+        return c.json(true)
+      },
+    )
     .get(
       "/tool/ids",
       describeRoute({

+ 71 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -25,6 +25,8 @@ import type {
   EventTuiSessionSelect,
   EventTuiToastShow,
   ExperimentalConsoleGetResponses,
+  ExperimentalConsoleListOrgsResponses,
+  ExperimentalConsoleSwitchOrgResponses,
   ExperimentalResourceListResponses,
   ExperimentalSessionListResponses,
   ExperimentalWorkspaceCreateErrors,
@@ -1012,6 +1014,75 @@ export class Console extends HeyApiClient {
       ...params,
     })
   }
+
+  /**
+   * List switchable Console orgs
+   *
+   * Get the available Console orgs across logged-in accounts, including the current active org.
+   */
+  public listOrgs<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
+      url: "/experimental/console/orgs",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Switch active Console org
+   *
+   * Persist a new active Console account/org selection for the current local OpenCode state.
+   */
+  public switchOrg<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+      accountID?: string
+      orgID?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+            { in: "body", key: "accountID" },
+            { in: "body", key: "orgID" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<ExperimentalConsoleSwitchOrgResponses, unknown, ThrowOnError>({
+      url: "/experimental/console/switch",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
 }
 
 export class Workspace extends HeyApiClient {

+ 52 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2675,6 +2675,58 @@ export type ExperimentalConsoleGetResponses = {
 
 export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
 
+export type ExperimentalConsoleListOrgsData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/console/orgs"
+}
+
+export type ExperimentalConsoleListOrgsResponses = {
+  /**
+   * Switchable Console orgs
+   */
+  200: {
+    orgs: Array<{
+      accountID: string
+      accountEmail: string
+      accountUrl: string
+      orgID: string
+      orgName: string
+      active: boolean
+    }>
+  }
+}
+
+export type ExperimentalConsoleListOrgsResponse =
+  ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses]
+
+export type ExperimentalConsoleSwitchOrgData = {
+  body?: {
+    accountID: string
+    orgID: string
+  }
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/console/switch"
+}
+
+export type ExperimentalConsoleSwitchOrgResponses = {
+  /**
+   * Switch success
+   */
+  200: boolean
+}
+
+export type ExperimentalConsoleSwitchOrgResponse =
+  ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses]
+
 export type ToolIdsData = {
   body?: never
   path?: never