Merge branch 'dev' into refactor/effectify-task-tool

pull/21017/head
Kit Langton 2026-04-04 19:33:26 -04:00
commit 8616818e37
41 changed files with 961 additions and 185 deletions

View File

@ -75,6 +75,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: packages/*/.artifacts/unit/junit.xml path: packages/*/.artifacts/unit/junit.xml

View File

@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -80,7 +80,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -114,7 +114,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -141,7 +141,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@ -165,7 +165,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -189,7 +189,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -222,7 +222,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -254,7 +254,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@ -283,7 +283,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -299,7 +299,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.13", "version": "1.3.15",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -428,7 +428,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@ -462,7 +462,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@ -477,7 +477,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -512,7 +512,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -560,7 +560,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@ -571,7 +571,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.13", "version": "1.3.15",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@ -15,7 +15,7 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "test": "bun run test:unit",
"test:ci": "bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src", "test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.13", "version": "1.3.15",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.13", "version": "1.3.15",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View File

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",

View File

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.13" version = "1.3.15"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.13", "version": "1.3.15",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.13", "version": "1.3.15",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -9,7 +9,7 @@
"prepare": "effect-language-service patch || true", "prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000",
"test:ci": "bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",

View File

@ -209,6 +209,7 @@ for (const item of targets) {
conditions: ["browser"], conditions: ["browser"],
tsconfig: "./tsconfig.json", tsconfig: "./tsconfig.json",
plugins: [plugin], plugins: [plugin],
external: ["node-gyp"],
compile: { compile: {
autoloadBunfig: false, autoloadBunfig: false,
autoloadDotenv: false, autoloadDotenv: false,

View File

@ -255,7 +255,7 @@ Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events - [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture - [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [ ] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream - [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock - [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling - [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events - [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events

View File

@ -52,6 +52,11 @@ export type AccountOrgs = {
orgs: readonly Org[] orgs: readonly Org[]
} }
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({ class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json), config: Schema.Record(Schema.String, Schema.Json),
}) {} }) {}
@ -137,6 +142,7 @@ const mapAccountServiceError =
export namespace Account { export namespace Account {
export interface Interface { export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError> readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError> readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
@ -279,19 +285,31 @@ export namespace Account {
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
) )
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list() const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition( return yield* Effect.forEach(
accounts, accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))), (account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 }, { concurrency: 3 },
) )
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
}) })
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) { const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
@ -396,6 +414,7 @@ export namespace Account {
return Service.of({ return Service.of({
active: repo.active, active: repo.active,
activeOrg,
list: repo.list, list: repo.list,
orgsByAccount, orgsByAccount,
remove: repo.remove, remove: repo.remove,
@ -417,6 +436,26 @@ export namespace Account {
return Option.getOrUndefined(await runPromise((service) => service.active())) return Option.getOrUndefined(await runPromise((service) => service.active()))
} }
export async function list(): Promise<Info[]> {
return runPromise((service) => service.list())
}
export async function activeOrg(): Promise<ActiveOrg | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
return runPromise((service) => service.orgs(accountID))
}
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> { export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
const t = await runPromise((service) => service.token(accountID)) const t = await runPromise((service) => service.token(accountID))
return Option.getOrUndefined(t) return Option.getOrUndefined(t)

View File

@ -36,6 +36,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-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 { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home" import { Home } from "@tui/routes/home"
@ -629,6 +630,23 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
category: "Provider", category: "Provider",
}, },
...(sync.data.console_state.switchableOrgCount > 1
? [
{
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", title: "View status",
keybind: "status_view", 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 { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() { export function useConnected() {
const sync = useSync() const sync = useSync()
@ -46,7 +47,11 @@ export function DialogModel(props: { providerID?: string }) {
key: item, key: item,
value: { providerID: provider.id, modelID: model.id }, value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID, title: model.name ?? item.modelID,
description: provider.name, description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
category, category,
disabled: provider.id === "opencode" && model.id.includes("-nano"), disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, 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) description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)" ? "(Favorite)"
: undefined, : 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"), disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() { onSelect() {
@ -132,7 +139,11 @@ export function DialogModel(props: { providerID?: string }) {
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, 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) { function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true }) local.model.set({ providerID, modelID }, { recent: true })

View File

@ -13,6 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = { const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0, opencode: 0,
@ -28,11 +29,16 @@ export function createDialogProviderOptions() {
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const { theme } = useTheme()
const options = createMemo(() => { const options = createMemo(() => {
return pipe( return pipe(
sync.data.provider_next.all, sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({ map((provider) => {
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
const connected = sync.data.provider_next.connected.includes(provider.id)
return {
title: provider.name, title: provider.name,
value: provider.id, value: provider.id,
description: { description: {
@ -41,8 +47,16 @@ export function createDialogProviderOptions() {
openai: "(ChatGPT Plus/Pro or API key)", openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone", "opencode-go": "Low cost subscription for everyone",
}[provider.id], }[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", 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() { async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[provider.id] ?? [ const methods = sync.data.provider_auth[provider.id] ?? [
{ {
type: "api", type: "api",
@ -95,12 +109,22 @@ export function createDialogProviderOptions() {
} }
if (result.data?.method === "code") { if (result.data?.method === "code") {
dialog.replace(() => ( dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} /> <CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
)) ))
} }
if (result.data?.method === "auto") { if (result.data?.method === "auto") {
dialog.replace(() => ( dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} /> <AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
)) ))
} }
} }
@ -108,7 +132,8 @@ export function createDialogProviderOptions() {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
} }
}, },
})), }
}),
) )
}) })
return options return options

View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@ -1050,11 +1051,13 @@ export namespace Config {
config: Info config: Info
directories: string[] directories: string[]
deps: Promise<void>[] deps: Promise<void>[]
consoleState: ConsoleState
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info> readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly update: (config: Info) => Effect.Effect<void> readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info> readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void> readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@ -1260,6 +1263,8 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {} let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = (source: string): PluginScope => { const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source.startsWith("http://") || source.startsWith("https://")) return "global"
@ -1371,26 +1376,31 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie)) const activeOrg = Option.getOrUndefined(
if (active?.active_org_id) { yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeOrg) {
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all( const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)], [accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
{ concurrency: 2 }, { concurrency: 2 },
) )
const token = Option.getOrUndefined(tokenOpt) if (Option.isSome(tokenOpt)) {
if (token) { process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
process.env["OPENCODE_CONSOLE_TOKEN"] = token Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
Env.set("OPENCODE_CONSOLE_TOKEN", token)
} }
const config = Option.getOrUndefined(configOpt) activeOrgName = activeOrg.org.name
if (config) {
const source = `${active.url}/api/config` if (Option.isSome(configOpt)) {
const next = yield* loadConfig(JSON.stringify(config), { const source = `${activeOrg.account.url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source), dir: path.dirname(source),
source, source,
}) })
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
merge(source, next, "global") merge(source, next, "global")
} }
}).pipe( }).pipe(
@ -1456,6 +1466,11 @@ export namespace Config {
config: result, config: result,
directories, directories,
deps, deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
} }
}) })
@ -1473,6 +1488,10 @@ export namespace Config {
return yield* InstanceState.use(state, (s) => s.directories) return yield* InstanceState.use(state, (s) => s.directories)
}) })
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined))) yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
}) })
@ -1528,6 +1547,7 @@ export namespace Config {
return Service.of({ return Service.of({
get, get,
getGlobal, getGlobal,
getConsoleState,
update, update,
updateGlobal, updateGlobal,
invalidate, invalidate,
@ -1553,6 +1573,10 @@ export namespace Config {
return runPromise((svc) => svc.getGlobal()) return runPromise((svc) => svc.getGlobal())
} }
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function update(config: Info) { export async function update(config: Info) {
return runPromise((svc) => svc.update(config)) return runPromise((svc) => svc.update(config))
} }

View File

@ -0,0 +1,15 @@
import z from "zod"
export const ConsoleState = z.object({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
}

View File

@ -67,6 +67,7 @@ export namespace Npm {
binLinks: true, binLinks: true,
progress: false, progress: false,
savePrefix: "", savePrefix: "",
ignoreScripts: true,
}) })
const tree = await arborist.loadVirtual().catch(() => {}) const tree = await arborist.loadVirtual().catch(() => {})
if (tree) { if (tree) {
@ -106,6 +107,7 @@ export namespace Npm {
binLinks: true, binLinks: true,
progress: false, progress: false,
savePrefix: "", savePrefix: "",
ignoreScripts: true,
}) })
await arb.reify().catch(() => {}) await arb.reify().catch(() => {})
} }

View File

@ -8,13 +8,116 @@ import { Instance } from "../../project/instance"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { Session } from "../../session" 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 { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace" 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(() => export const ExperimentalRoutes = lazy(() =>
new Hono() new Hono()
.get(
"/console",
describeRoute({
summary: "Get active Console provider metadata",
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
operationId: "experimental.console.get",
responses: {
200: {
description: "Active Console provider metadata",
content: {
"application/json": {
schema: resolver(ConsoleState),
},
},
},
},
}),
async (c) => {
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
return c.json({
...consoleState,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
})
},
)
.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( .get(
"/tool/ids", "/tool/ids",
describeRoute({ describeRoute({

View File

@ -278,7 +278,7 @@ export namespace Session {
const tokens = { const tokens = {
total, total,
input: adjustedInputTokens, input: adjustedInputTokens,
output: outputTokens, output: outputTokens - reasoningTokens,
reasoning: reasoningTokens, reasoning: reasoningTokens,
cache: { cache: {
write: cacheWriteInputTokens, write: cacheWriteInputTokens,

View File

@ -82,25 +82,6 @@ If the `AGENTS.md` is empty or insufficient, you may check `README`/`README.md`
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date. If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
# Skills
Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.
## What are skills?
Skills are modular extensions that provide:
- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)
- Workflow patterns: Best practices for common tasks
- Tool integrations: Pre-configured tool chains for specific operations
- Reference material: Documentation, templates, and examples
## How to use skills
Identify the skills that are likely to be useful for the tasks you are currently working on, use the `skill` tool to load a skill for detailed instructions, guidelines, scripts and more.
Only load skill details when needed to conserve the context window.
# Ultimate Reminders # Ultimate Reminders
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations. At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.

View File

@ -25,6 +25,7 @@ import { Npm } from "../../src/npm"
const emptyAccount = Layer.mock(Account.Service)({ const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()), active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
}) })
const emptyAuth = Layer.mock(Auth.Service)({ const emptyAuth = Layer.mock(Auth.Service)({
@ -282,6 +283,21 @@ test("resolves env templates in account config with account token", async () =>
active_org_id: OrgID.make("org-1"), active_org_id: OrgID.make("org-1"),
}), }),
), ),
activeOrg: () =>
Effect.succeed(
Option.some({
account: {
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
},
org: {
id: OrgID.make("org-1"),
name: "Example Org",
},
}),
),
config: () => config: () =>
Effect.succeed( Effect.succeed(
Option.some({ Option.some({

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@ -24,6 +24,9 @@ import type {
EventTuiPromptAppend, EventTuiPromptAppend,
EventTuiSessionSelect, EventTuiSessionSelect,
EventTuiToastShow, EventTuiToastShow,
ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListResponses, ExperimentalResourceListResponses,
ExperimentalSessionListResponses, ExperimentalSessionListResponses,
ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateErrors,
@ -981,13 +984,13 @@ export class Config2 extends HeyApiClient {
} }
} }
export class Tool extends HeyApiClient { export class Console extends HeyApiClient {
/** /**
* List tool IDs * Get active Console provider metadata
* *
* Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. * Get the active Console org name and the set of provider IDs managed by that Console org.
*/ */
public ids<ThrowOnError extends boolean = false>( public get<ThrowOnError extends boolean = false>(
parameters?: { parameters?: {
directory?: string directory?: string
workspace?: string workspace?: string
@ -1005,24 +1008,22 @@ export class Tool extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({ return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
url: "/experimental/tool/ids", url: "/experimental/console",
...options, ...options,
...params, ...params,
}) })
} }
/** /**
* List tools * List switchable Console orgs
* *
* Get a list of available tools with their JSON schema parameters for a specific provider and model combination. * Get the available Console orgs across logged-in accounts, including the current active org.
*/ */
public list<ThrowOnError extends boolean = false>( public listOrgs<ThrowOnError extends boolean = false>(
parameters: { parameters?: {
directory?: string directory?: string
workspace?: string workspace?: string
provider: string
model: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@ -1033,18 +1034,55 @@ export class Tool extends HeyApiClient {
args: [ args: [
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
], ],
}, },
], ],
) )
return (options?.client ?? this.client).get<ToolListResponses, ToolListErrors, ThrowOnError>({ return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
url: "/experimental/tool", url: "/experimental/console/orgs",
...options, ...options,
...params, ...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 { export class Workspace extends HeyApiClient {
@ -1239,6 +1277,11 @@ export class Resource extends HeyApiClient {
} }
export class Experimental extends HeyApiClient { export class Experimental extends HeyApiClient {
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))
}
private _workspace?: Workspace private _workspace?: Workspace
get workspace(): Workspace { get workspace(): Workspace {
return (this._workspace ??= new Workspace({ client: this.client })) return (this._workspace ??= new Workspace({ client: this.client }))
@ -1255,6 +1298,72 @@ export class Experimental extends HeyApiClient {
} }
} }
export class Tool extends HeyApiClient {
/**
* List tool IDs
*
* Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.
*/
public ids<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<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
url: "/experimental/tool/ids",
...options,
...params,
})
}
/**
* List tools
*
* Get a list of available tools with their JSON schema parameters for a specific provider and model combination.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
directory?: string
workspace?: string
provider: string
model: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
],
},
],
)
return (options?.client ?? this.client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
url: "/experimental/tool",
...options,
...params,
})
}
}
export class Worktree extends HeyApiClient { export class Worktree extends HeyApiClient {
/** /**
* Remove worktree * Remove worktree
@ -4017,16 +4126,16 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client })) return (this._config ??= new Config2({ client: this.client }))
} }
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
}
private _experimental?: Experimental private _experimental?: Experimental
get experimental(): Experimental { get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client })) return (this._experimental ??= new Experimental({ client: this.client }))
} }
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
}
private _worktree?: Worktree private _worktree?: Worktree
get worktree(): Worktree { get worktree(): Worktree {
return (this._worktree ??= new Worktree({ client: this.client })) return (this._worktree ??= new Worktree({ client: this.client }))

View File

@ -2653,6 +2653,81 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ExperimentalConsoleGetData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/console"
}
export type ExperimentalConsoleGetResponses = {
/**
* Active Console provider metadata
*/
200: {
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
}
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 = { export type ToolIdsData = {
body?: never body?: never
path?: never path?: never

View File

@ -1220,6 +1220,194 @@
] ]
} }
}, },
"/experimental/console": {
"get": {
"operationId": "experimental.console.get",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Get active Console provider metadata",
"description": "Get the active Console org name and the set of provider IDs managed by that Console org.",
"responses": {
"200": {
"description": "Active Console provider metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})"
}
]
}
},
"/experimental/console/orgs": {
"get": {
"operationId": "experimental.console.listOrgs",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "List switchable Console orgs",
"description": "Get the available Console orgs across logged-in accounts, including the current active org.",
"responses": {
"200": {
"description": "Switchable Console orgs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"orgs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"accountID": {
"type": "string"
},
"accountEmail": {
"type": "string"
},
"accountUrl": {
"type": "string"
},
"orgID": {
"type": "string"
},
"orgName": {
"type": "string"
},
"active": {
"type": "boolean"
}
},
"required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"]
}
}
},
"required": ["orgs"]
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})"
}
]
}
},
"/experimental/console/switch": {
"post": {
"operationId": "experimental.console.switchOrg",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Switch active Console org",
"description": "Persist a new active Console account/org selection for the current local OpenCode state.",
"responses": {
"200": {
"description": "Switch success",
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"accountID": {
"type": "string"
},
"orgID": {
"type": "string"
}
},
"required": ["accountID", "orgID"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})"
}
]
}
},
"/experimental/tool/ids": { "/experimental/tool/ids": {
"get": { "get": {
"operationId": "tool.ids", "operationId": "tool.ids",

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.13", "version": "1.3.15",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.13", "version": "1.3.15",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View File

@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.3.13", "version": "1.3.15",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.3.13", "version": "1.3.15",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",