Merge remote-tracking branch 'upstream/dev' into refactor/tanstack-virtual-session

# Conflicts:
#	bun.lock
#	package.json
This commit is contained in:
LukeParkerDev 2026-06-14 20:58:33 +02:00
commit 6ad0e88657
186 changed files with 6919 additions and 3275 deletions

View file

@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.

View file

@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -85,7 +85,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.4",
"version": "1.17.7",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@ -110,7 +110,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@ -146,7 +146,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -173,7 +173,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@ -195,7 +195,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -219,7 +219,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@ -239,7 +239,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.4",
"version": "1.17.7",
"bin": {
"opencode": "./bin/opencode",
},
@ -330,7 +330,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@ -384,7 +384,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@ -398,7 +398,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"effect": "catalog:",
},
@ -410,7 +410,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -441,7 +441,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -457,7 +457,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@ -476,7 +476,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@ -494,7 +494,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.4",
"version": "1.17.7",
"bin": {
"opencode": "./bin/opencode",
},
@ -622,7 +622,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@ -660,7 +660,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"cross-spawn": "catalog:",
},
@ -675,7 +675,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@ -689,7 +689,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -702,7 +702,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@ -735,7 +735,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@ -754,7 +754,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@ -794,7 +794,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@ -821,7 +821,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -870,7 +870,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.4",
"version": "1.17.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -918,8 +918,9 @@
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {

View file

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
"x86_64-linux": "sha256-Y+8gz+NfFSb4dysjho4CCaQpx012vvajnptfd3cdh/k=",
"aarch64-linux": "sha256-dfkEJ57XvxCM4huM5Ij7tSy+JUPKYmBMZDGZKZ5xSXA=",
"aarch64-darwin": "sha256-CJ8dvzGzJp5sgaNJYHwxRc1xK9bWA8zyTmDFi4wTI3o=",
"x86_64-darwin": "sha256-7/kwFObujL4z4544Q0D2LN/SxT8sHnJyVrzmBXbJajA="
}
}

View file

@ -150,6 +150,7 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch"
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.4",
"version": "1.17.7",
"description": "",
"type": "module",
"exports": {

View file

@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk.directory) return
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
if (!tabs.ready() || !sdk().directory) return
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
@ -82,6 +82,45 @@ const SessionRoute = Object.assign(
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server.
function DraftServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
@ -95,19 +134,15 @@ function DraftRoute() {
}
function ResolvedDraftRoute(props: { draftID: string }) {
const server = useServer()
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
createEffect(() => {
const current = draft()
if (current && current.server !== server.key) server.setActive(current.server)
})
// Key on the directory so retargeting the draft's project re-instantiates the
// SDK/data providers for the new directory while keeping the same draft id.
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return (
@ -171,27 +206,36 @@ function BodyDesignClass() {
return null
}
function AppShellProviders(props: ParentProps) {
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
<CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</SettingsProvider>
)
}
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) {
return (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) {
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
{/*<Suspense fallback={<Loading />}>*/}
{props.appChildren}
{props.children}
{/*</Suspense>*/}
</AppShellProviders>
)
}
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return (
<MetaProvider>
@ -385,6 +418,20 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
// Command/Highlights) and stays mounted across every route. The server-scoped
// providers and the visual Layout live in the per-route layouts below, so they
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
)
return (
<ServerProvider
defaultServer={props.defaultServer}
@ -397,23 +444,19 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
<ServerShell>{routerProps.children}</ServerShell>
</TabsProvider>
)}
>
<Route path="/" component={HomeRoute} />
<Route path="/new-session" component={DraftRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
<Route component={SelectedServerLayout}>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>

View file

@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync.data.provider_auth[props.provider]
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK.client.provider.auth()
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
serverSync.set("provider_auth", res.data ?? {})
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK.client.provider.oauth
.authorize(
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
async function complete() {
await serverSDK.client.global.dispose()
await serverSDK().client.global.dispose()
dialog.close()
showToast({
variant: "success",
@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
await serverSDK.client.auth.set({
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
const result = await serverSDK.client.provider.oauth
.callback({
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
const result = await serverSDK.client.provider.oauth
.callback({
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})

View file

@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK.client.auth.set({
await serverSDK().client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
})
}
await serverSync.updateConfig({
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})

View file

@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync.data.message[sessionID] ?? []
const msgs = sync().data.message[sessionID] ?? []
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync.data.part[message.id] ?? []
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const parts = sync.data.part[item.id] ?? []
const parts = sync().data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, {
directory: sdk.directory,
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(sdk.directory)
const dir = base64Encode(sdk().directory)
sdk.client.session
.fork({ sessionID, messageID: item.id })
sdk()
.client.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })

View file

@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
@ -175,7 +175,7 @@ function createFileEntries(props: {
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ReturnType<typeof useServerSDK>
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync.data.path.home)
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
const items = async (text: string) => {
const query = text.trim()

View file

@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
const language = useLanguage()
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
Object.entries(sync().data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const mcpStatus = () => sync().data.mcp[i.name]
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined

View file

@ -0,0 +1,117 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { createPromptState } from "@/context/prompt"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function PromptInputExample() {
const state = createPromptState()
const history = createPromptInputHistory()
const [controls, setControls] = createStore({
agent: "build",
variant: undefined as string | undefined,
comments: 0,
tabs: [] as string[],
activeTab: undefined as string | undefined,
reviewOpen: false,
})
const model = {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: {
list: () => ["fast", "thinking"],
current: () => controls.variant,
set: (variant?: string) => setControls("variant", variant),
},
}
const submission = {
abort() {},
handleSubmit(event: Event) {
event.preventDefault()
state.reset()
},
}
const inputControls = {
agents: {
available: [{ name: "review", hidden: false, mode: "subagent" }],
options: ["build", "review", "plan"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: model,
paid: true,
loading: false,
},
projects: {
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
directory: "/tmp/story",
select() {},
add() {},
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => controls.tabs,
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: {
opened: () => controls.reviewOpen,
open: () => setControls("reviewOpen", true),
},
},
newLayoutDesigns: true,
}
const addReviewComment = () => {
const comment = controls.comments + 1
setControls("comments", comment)
state.context.add({
type: "file",
path: "src/components/prompt-input.tsx",
selection: {
startLine: 84 + comment,
startChar: 0,
endLine: 84 + comment,
endChar: 0,
},
comment: `Review comment ${comment}`,
commentID: `review-comment-${comment}`,
commentOrigin: "review",
preview: "export const PromptInput = ...",
})
}
return (
<div class="flex flex-col gap-3">
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
<div>
<button
type="button"
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
onClick={addReviewComment}
>
Add review comment
</button>
</div>
</div>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
component: PromptInput,
}
export const Basic = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input</h1>
<PromptInputExample />
</div>
),
}

View file

@ -17,8 +17,8 @@ import {
type JSX,
} from "solid-js"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import {
ContentPart,
@ -31,10 +31,7 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
@ -73,18 +67,104 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { useDirectoryPicker } from "./directory-picker"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
interface PromptInputProps {
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
projects: {
available: { name?: string; worktree: string; sandboxes?: string[] }[]
directory: string
select: (worktree: string) => void
add: (title: string) => void
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
newLayoutDesigns: boolean
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
@ -126,27 +206,18 @@ const EXAMPLES = [
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const navigate = useNavigate()
const queryOptions = useQueryOptions()
const sync = useSync()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const prompt = props.state ?? usePrompt()
const layout = useLayout()
const server = useServer()
const comments = useComments()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const tabsStore = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const { params, tabs, view } = useSessionLayout()
const tabs = () => props.controls.session.tabs
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
@ -204,10 +275,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}).activeFileTab
const commentInReview = (path: string) => {
const sessionID = params.id
const sessionID = props.controls.session.id
if (!sessionID) return false
const diffs = sync.data.session_diff[sessionID]
const diffs = sync().data.session_diff[sessionID]
if (!diffs) return false
return diffs.some((diff) => diff.file === path)
}
@ -237,14 +308,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
queueCommentFocus()
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void tabs().open(tab)
@ -269,8 +340,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@ -347,29 +418,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
const sessionID = props.controls.session.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
const messages = sync().data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
)
const history = props.history ?? createPersistedPromptInputHistory()
const suggest = createMemo(() => !hasUserPrompt())
@ -474,7 +530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pick = () => {
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
directory: () => sdk().directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
@ -573,8 +629,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
createEffect(() => {
params.id
if (params.id) return
props.controls.session.id
if (props.controls.session.id) return
if (!suggest()) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
@ -603,11 +659,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const agentList = createMemo(() =>
sync.data.agent
props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
@ -673,7 +728,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "builtin" as const,
}))
const custom = sync.data.command.map((cmd) => ({
const custom = sync().data.command.map((cmd) => ({
id: `custom.${cmd.name}`,
trigger: cmd.name,
title: cmd.name,
@ -1033,11 +1088,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return
setCurrentHistory("entries", next)
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
}
createEffect(
@ -1082,7 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({
direction,
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
entries: history.entries(store.mode),
historyIndex: store.historyIndex,
currentPrompt: prompt.current(),
currentComments: historyComments(),
@ -1096,6 +1147,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
prompt,
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@ -1122,38 +1174,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
)
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
// Check provider variants directly: `variants` also includes the UI-only default option.
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const { abort, handleSubmit } = createPromptSubmit({
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
const { abort, handleSubmit } =
props.submission ??
createPromptSubmit({
prompt,
info,
imageAttachments,
commentCount,
autoAccept: () => accepting(),
mode: () => store.mode,
working,
editor: () => editorRef,
queueScroll,
promptLength,
addToHistory,
resetHistoryNavigation: () => {
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@ -1317,17 +1372,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [
queryOptions.agents(pathKey(sdk.directory)),
queryOptions.providers(null),
queryOptions.providers(pathKey(sdk.directory)),
],
}))
const agentsLoading = () => agentsQuery.isLoading
const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
@ -1342,23 +1389,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(),
paid: providers.paid().length > 0,
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"),
model: local.model,
providerID: local.model.current()?.provider?.id,
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
})
},
}))
const newSession = () => props.variant === "new-session"
const projects = createMemo(() => layout.projects.list())
const projects = createMemo(() => props.controls.projects.available)
const projectForDirectory = (directory: string | undefined) => {
if (!directory) return
const key = pathKey(directory)
@ -1366,13 +1413,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
}
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
const projectResults = createMemo(() => {
const search = picker.projectSearch.trim().toLowerCase()
if (!search) return projects()
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
})
const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0)
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string) => {
setPicker({
projectOpen: false,
@ -1382,33 +1429,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFocus()
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
// On the draft route, retarget the existing draft in place so we keep the same
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
const draftID = search.draftId
if (draftID) {
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
restoreFocus()
return
}
navigate(`/${base64Encode(worktree)}/session`)
props.controls.projects.select(worktree)
restoreFocus()
}
const addProject = () => {
const conn = server.current
if (!conn) return
const select = (result: string | string[] | null) => {
const directory = Array.isArray(result) ? result[0] : result
if (!directory) return
selectProject(directory)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
onSelect: select,
})
props.controls.projects.add(language.t("command.project.open"))
}
const projectPickerState = createMemo<ComposerPickerState>(() => ({
@ -1449,11 +1474,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: agentNames(),
current: local.agent.current()?.name ?? "",
options: props.controls.agents.options,
current: props.controls.agents.current,
style: control(),
onSelect: (value) => {
local.agent.set(value)
props.controls.agents.select(value)
restoreFocus()
},
}))
@ -1485,7 +1510,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={settings.general.newLayoutDesigns()}>
<Match when={props.controls.newLayoutDesigns}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@ -1605,7 +1630,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
data-component="prompt-variant-control"
classList={{
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!local.model.variant.current() && !store.variantOpen,
!props.controls.model.selection.variant.current() && !store.variantOpen,
}}
>
<TooltipKeybind
@ -1617,11 +1642,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
@ -1861,10 +1886,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
options={props.controls.agents.options}
current={props.controls.agents.current}
onSelect={(value) => {
local.agent.set(value)
props.controls.agents.select(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
@ -1883,7 +1908,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
when={props.controls.model.paid}
fallback={
<TooltipKeybind
placement="top"
@ -1900,19 +1925,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
dialog.show(() => (
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
@ -1926,7 +1954,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
model={props.controls.model.selection}
triggerAs={Button}
triggerProps={{
variant: "ghost",
@ -1937,15 +1965,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
@ -1966,10 +1995,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"

View file

@ -1,7 +1,7 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
@ -26,6 +26,7 @@ function dataUrl(file: File, mime: string) {
}
type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
@ -35,7 +36,7 @@ type PromptAttachmentsInput = {
}
export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const prompt = input.prompt
const language = useLanguage()
const warn = () => {

View file

@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}>
{(sel) => (

View file

@ -26,6 +26,22 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
removeComment: () => undefined,
updateComment: () => undefined,
replaceComments: () => undefined,
items: () => [],
},
}
const clientFor = (directory: string) => {
createdClients.push(directory)
@ -73,6 +89,7 @@ beforeAll(async () => {
}))
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0,
}))
@ -116,16 +133,7 @@ beforeAll(async () => {
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
reset: () => undefined,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
items: () => [],
},
}),
usePrompt: () => prompt,
}))
mock.module("@/context/layout", () => ({
@ -147,12 +155,12 @@ beforeAll(async () => {
return clientFor(opts.directory)
},
}
return sdk
return () => sdk
},
}))
mock.module("@/context/sync", () => ({
useSync: () => ({
useSync: () => () => ({
data: { command: [] },
session: {
optimistic: {
@ -176,7 +184,7 @@ beforeAll(async () => {
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => ({
useServerSync: () => () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
@ -232,6 +240,7 @@ beforeEach(() => {
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
@ -269,6 +278,7 @@ describe("prompt submit worktree selection", () => {
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
@ -299,6 +309,7 @@ describe("prompt submit worktree selection", () => {
variant = "high"
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
@ -330,6 +341,7 @@ describe("prompt submit worktree selection", () => {
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,

View file

@ -7,14 +7,14 @@ import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
@ -40,9 +40,9 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
serverSync: ReturnType<typeof useServerSync>
sync: ReturnType<typeof useSync>
client: DirectorySDK["client"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
@ -174,6 +174,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
@ -211,14 +212,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const serverSync = useServerSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const prompt = input.prompt
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
@ -233,8 +234,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory)
serverSync().todo.set(sessionID, [])
const [, setStore] = serverSync().child(sdk().directory)
setStore("todo", sessionID, [])
input.onAbort?.()
@ -247,8 +248,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
pending.delete(key)
return Promise.resolve()
}
return sdk.client.session
.abort({
return sdk()
.client.session.abort({
sessionID,
})
.catch(() => {})
@ -281,7 +282,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: Session) => {
const [, setStore] = serverSync.child(dir)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@ -321,13 +322,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk.directory
const projectDirectory = sdk().directory
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk.client
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
@ -349,7 +350,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return
}
WorktreeState.pending(sdk.scope, createdWorktree.directory)
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@ -358,11 +359,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync.child(sessionDirectory)
serverSync().child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@ -470,7 +471,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
@ -504,7 +505,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
@ -515,17 +516,17 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
sync().set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
restoreCommentItems(commentItems)
@ -559,12 +560,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
},
)
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
@ -573,8 +576,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
client,
sync,
serverSync,
sync: sync(),
serverSync: serverSync(),
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@ -582,7 +585,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),

View file

@ -42,7 +42,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const usd = createMemo(
() =>

View file

@ -96,13 +96,13 @@ export function SessionContextTab() {
const providers = useProviders()
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
return (sync().data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
@ -180,7 +180,7 @@ export function SessionContextTab() {
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
parts: sync().data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
@ -221,7 +221,7 @@ export function SessionContextTab() {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll

View file

@ -229,7 +229,7 @@ export function SessionHeader() {
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),

View file

@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
const sdk = useSDK()
const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const isWorktree = createMemo(() => {
const project = sync.project
const project = sync().project
if (!project) return false
return sdk.directory !== project.worktree
return sdk().directory !== project.worktree
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main")
const branch = sync.data.vcs?.branch
const branch = sync().data.vcs?.branch
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main")
}
@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{label(current())}
</div>
</div>
<Show when={sync.project}>
<Show when={sync().project}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">

View file

@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
const [shells] = createResource(
() =>
serverSdk.client.pty
.shells()
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"

View file

@ -83,7 +83,7 @@ const SettingsProvidersContent: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@ -91,11 +91,11 @@ const SettingsProvidersContent: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
serverSync().set("config", "disabled_providers", next)
await serverSync
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@ -106,7 +106,7 @@ const SettingsProvidersContent: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@ -114,14 +114,16 @@ const SettingsProvidersContent: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK.client.auth
.remove({ providerID })
await serverSDK()
.client.auth.remove({ providerID })
.then(async () => {
await serverSDK.client.global.dispose()
await serverSDK().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",

View file

@ -30,7 +30,7 @@ function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnecti
return (
<QueryClientProvider client={serverCtx().queryClient}>
<ServerSDKProvider server={props.server}>
<ServerSDKProvider server={() => props.server}>
<ServerSyncProvider>
<ModelsProvider>{props.children}</ModelsProvider>
</ServerSyncProvider>

View file

@ -126,8 +126,8 @@ export const SettingsGeneralV2: Component = () => {
const [shells] = createResource(
() =>
serverSdk.client.pty
.shells()
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
@ -144,11 +144,11 @@ export const SettingsGeneralV2: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@ -275,7 +275,7 @@ export const SettingsGeneralV2: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>

View file

@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = serverSync().data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
serverSync().set("config", "disabled_providers", next)
await serverSync
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@ -108,14 +108,16 @@ export const SettingsProvidersV2: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSdk.client.auth
.remove({ providerID })
await serverSdk()
.client.auth.remove({ providerID })
.then(async () => {
await serverSdk.client.global.dispose()
await serverSdk().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",

View file

@ -279,13 +279,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
(sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
)
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))

View file

@ -18,9 +18,9 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
@ -86,9 +86,9 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const

View file

@ -161,9 +161,9 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
const language = useLanguage()
const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const directory = sdk().directory
const client = sdk().client
const url = sdk().url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""

View file

@ -291,7 +291,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const sync = serverSync.createDirSyncContext(route.dir)
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
@ -312,7 +312,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (tab) return
if (route.type === "session") {
const sync = serverSync.createDirSyncContext(route.dir)
const sync = serverSync().createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id

View file

@ -208,7 +208,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(
serverSDK.scope,
serverSDK().scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),

View file

@ -11,7 +11,7 @@ import {
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { createServerSdkContext, useServerSDK } from "./server-sdk"
import { type createServerSdkContext } from "./server-sdk"
import { type createServerSyncContextInner } from "./server-sync"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@ -174,7 +174,7 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
export const createDirSyncContext = (
directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })

View file

@ -62,10 +62,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const language = useLanguage()
const layout = useLayout()
const scope = createMemo(() => sdk.directory)
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
)
const inflight = new Map<string, Promise<void>>()
@ -78,7 +78,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const tree = createFileTreeStore({
scope,
normalizeDir: path.normalizeDir,
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
list: (dir) =>
sdk()
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
onError: (message) => {
showToast({
variant: "error",
@ -112,7 +115,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache(serverSDK.scope)
const viewCache = createFileViewCache(serverSDK().scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
@ -176,8 +179,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk.client.file
.read({ path: file })
const promise = sdk()
.client.file.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
@ -200,12 +203,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false") =>
sdk.client.find.files({ query, dirs }).then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
)
sdk()
.client.find.files({ query, dirs })
.then(
(x) => (x.data ?? []).map(path.normalize),
() => [],
)
const stop = sdk.event.listen((e) => {
const stop = sdk().event.listen((e) => {
invalidateFromWatcher(e.details, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),

View file

@ -246,7 +246,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
createStore({
@ -409,11 +409,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync.data.project.find((x) => x.id === projectID)
: serverSync.data.project.find((x) => x.worktree === project.worktree)
? serverSync().data.project.find((x) => x.id === projectID)
: serverSync().data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
@ -427,7 +427,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync.data.project) {
for (const project of serverSync().data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
@ -493,12 +493,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync.ready) return
if (!serverSync().ready) return
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync.project.icon(project.worktree, project.icon?.override)
serverSync().project.icon(project.worktree, project.icon?.override)
}
})
@ -532,12 +532,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync.project.meta(worktree, { icon: { color } })
serverSync().project.meta(worktree, { icon: { color } })
continue
}
void serverSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
void serverSdk()
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
@ -554,7 +554,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync.project.loadSessions(project.worktree)
return serverSync().project.loadSessions(project.worktree)
}),
)
}, 0)
@ -584,7 +584,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync.project.loadSessions(root)
void serverSync().project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {

View file

@ -64,12 +64,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@ -124,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(serverSDK.scope, sdk.directory, session)
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@ -144,8 +144,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const configured = sync().data.config.model
if (!configured) return
const [providerID, modelID] = configured.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
@ -363,7 +364,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const result = {
slug: createMemo(() => base64Encode(sdk.directory)),
slug: createMemo(() => base64Encode(sdk().directory)),
model,
agent,
session: {
@ -374,13 +375,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
if (dir === sdk().directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@ -388,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, {
agent: msg.agent,

View file

@ -8,7 +8,7 @@ export function useMcpToggle() {
const language = useLanguage()
return useMutation(() => ({
mutationFn: sync.mcp.toggle,
mutationFn: sync().mcp.toggle,
onError: (error) =>
showToast({
variant: "error",

View file

@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
@ -208,11 +208,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = serverSync.child(directory, { bootstrap: false })
const [syncStore] = serverSync().child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return serverSDK.client.session
.get({ directory, sessionID })
return serverSDK()
.client.session.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
}
@ -286,7 +286,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
}
const unsub = serverSDK.event.listen((e) => {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return

View file

@ -55,13 +55,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
if (!directory) return false
const [store] = serverSync.child(directory)
const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
@ -87,7 +87,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = serverSync.child(directory)
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
@ -119,9 +119,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
serverSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
serverSDK()
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
}
function respondOnce(permission: PermissionRequest, directory?: string) {
@ -140,7 +142,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
@ -149,7 +151,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
@ -160,7 +162,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next
}
const unsubscribe = serverSDK.event.listen((e) => {
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@ -179,8 +181,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
.list({ directory })
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
@ -211,8 +213,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
.list({ directory })
serverSDK()
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
@ -269,7 +271,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = serverSync.child(directory)
const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},

View file

@ -153,6 +153,14 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptStore = {
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}
type Scope = { draftID: string } | { dir: string; id?: string }
function scopeKey(scope: Scope) {
@ -174,25 +182,26 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}),
createStore<PromptStore>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
const actions = createPromptActions(setStore)
return {
ready,
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@ -232,6 +241,15 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
}
}
export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready: () => ready,
...createPromptStateValue(store, setStore),
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
@ -272,7 +290,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK.scope, scope),
value: createPromptSession(serverSDK().scope, scope),
dispose,
}),
owner,

View file

@ -1,11 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { type Accessor, createMemo } from "solid-js"
import { type ServerSDK, useServerSDK } from "./server-sdk"
export type DirectorySDK = ReturnType<ServerSDK["createDirSdkContext"]>
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
init: (props: { directory: string | Accessor<string> }) => {
const serverSDK = useServerSDK()
return serverSDK.createDirSdkContext(props.directory)
return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().createDirSdkContext(directory)
})
},
})

View file

@ -2,7 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
@ -21,7 +21,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@ -261,21 +261,31 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
}
}
export type ServerSDK = ReturnType<typeof createServerSdkContext>
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
createDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope)
return Object.assign(sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
})
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: (props: { server?: ServerConnection.Any }) => {
// Returns an accessor so the resolved server can change reactively (e.g. a
// /new-session draft retargeting its server) without re-instantiating the subtree.
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return Object.assign(ctx.sdk, {
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
return createMemo<ServerSDK>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sdk
})
},
})
@ -284,7 +294,7 @@ type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
const client = serverSDK.createClient({
directory,
throwOnError: true,

View file

@ -1,11 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { ServerSDK, useServerSDK } from "./server-sdk"
import { ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@ -84,8 +84,7 @@ function makeQueryOptionsApi(
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
export function createServerSyncContextInner(serverSDK: ServerSDK) {
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
@ -507,33 +506,37 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}
}
export function createServerSyncContext(_serverSDK?: ServerSDK) {
const inner = createServerSyncContextInner(_serverSDK)
export function createServerSyncContext(serverSDK: ServerSDK) {
const inner = createServerSyncContextInner(serverSDK)
return Object.assign(inner, {
createDirSyncContext: createRefCountMap(
(dir) => createDirSyncContext(dir, inner, _serverSDK),
(dir) => createDirSyncContext(dir, inner, serverSDK),
(dir) => inner.disableMcp(dir),
directoryKey,
),
})
}
export type ServerSync = ReturnType<typeof createServerSyncContext>
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
gate: false,
init: (props: { server?: ServerConnection.Any }) => {
// Returns an accessor so the resolved server can change reactively without
// re-instantiating the subtree (mirrors useServerSDK).
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
const conn = props.server ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const ctx = global.createServerCtx(conn)
return ctx.sync
return createMemo<ServerSync>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.createServerCtx(conn).sync
})
},
})
export function useQueryOptions() {
return useServerSync().queryOptions
const sync = useServerSync()
return createMemo(() => sync().queryOptions)
}

View file

@ -1,4 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@ -112,5 +113,7 @@ export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
return serverSync.createDirSyncContext(sdk.directory)
return createMemo(() => serverSync().createDirSyncContext(sdk().directory))
}
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>

View file

@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform"
import { useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
@ -143,7 +143,7 @@ export function clearWorkspaceTerminals(
}
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
sdk: DirectorySDK,
dir: string,
scope: ServerScopeValue,
legacySessionID?: string,
@ -202,7 +202,7 @@ function createWorkspaceTerminalSession(
})
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
@ -223,7 +223,7 @@ function createWorkspaceTerminalSession(
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const clone = async (client: DirectorySDK["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
@ -412,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
dispose,
}))

View file

@ -22,10 +22,10 @@ export function useProviders() {
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = serverSync.child(dir())
const [projectStore] = serverSync().child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync.data.provider
return serverSync().data.provider
}
return {
all: () => providers().all,

View file

@ -20,7 +20,7 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return
const next = sync.data.path.directory
const next = sync().data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
@ -28,12 +28,15 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
createResource(
() => params.id,
(id) => sync.session.sync(id).catch(() => {}),
(id) =>
sync()
.session.sync(id)
.catch(() => {}),
)
return (
<DataProvider
data={sync.data}
data={sync().data}
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}

View file

@ -24,7 +24,7 @@ import { DialogSelectServer, useServerManagementController } from "@/components/
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import {
@ -80,7 +80,7 @@ const HOME_SEARCH_RESULT_META =
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: Pick<ReturnType<typeof useServerSync>, "child">
sync: Pick<ServerSync, "child">
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
@ -149,7 +149,7 @@ function HomeDesign() {
if (!conn) return
return global.createServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
const newSessionProject = createMemo(
@ -1102,10 +1102,10 @@ function LegacyHome() {
const global = useGlobal()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const homedir = createMemo(() => sync().data.path.home)
const recent = createMemo(() => {
return sync.data.project
.slice()
return sync()
.data.project.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
@ -1164,7 +1164,7 @@ function LegacyHome() {
{server.name}
</Button>
<Switch>
<Match when={sync.data.project.length > 0}>
<Match when={sync().data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
@ -1191,7 +1191,7 @@ function LegacyHome() {
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<Match when={!sync().ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>

View file

@ -95,7 +95,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
activeProject: undefined as string | undefined,
@ -140,7 +140,7 @@ export default function Layout(props: ParentProps) {
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
const store = serverSync.peek(dir, { bootstrap: false })
const store = serverSync().peek(dir, { bootstrap: false })
return {
slug,
store,
@ -213,7 +213,7 @@ export default function Layout(props: ParentProps) {
active: () => state.hoverProject,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
onActivate: (directory) => {
serverSync.child(directory)
serverSync().child(directory)
setState("hoverProject", directory)
},
})
@ -397,17 +397,17 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.delete(sessionKey)
}
const unsub = serverSDK.event.listen((e) => {
const unsub = serverSDK().event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(serverSDK.scope, e.name)
WorktreeState.ready(serverSDK().scope, e.name)
return
}
if (e.details?.type === "worktree.failed") {
setBusy(e.name, false)
WorktreeState.failed(
serverSDK.scope,
serverSDK().scope,
e.name,
e.details.properties?.message ?? language.t("common.requestFailed"),
)
@ -435,7 +435,7 @@ export default function Layout(props: ParentProps) {
const props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = serverSync().child(directory, { bootstrap: false })
const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${props.sessionID}`
@ -498,7 +498,7 @@ export default function Layout(props: ParentProps) {
if (!currentDir() || !currentSession) return
const sessionKey = `${currentDir()}:${currentSession}`
dismissSessionAlert(sessionKey)
const [store] = serverSync.child(currentDir(), { bootstrap: false })
const [store] = serverSync().child(currentDir(), { bootstrap: false })
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
dismissSessionAlert(`${currentDir()}:${child.id}`)
@ -536,11 +536,11 @@ export default function Layout(props: ParentProps) {
const direct = projects.find((p) => pathKey(p.worktree) === key)
if (direct) return direct
const [child] = serverSync.child(directory, { bootstrap: false })
const [child] = serverSync().child(directory, { bootstrap: false })
const id = child.project
if (!id) return
const meta = serverSync.data.project.find((p) => p.id === id)
const meta = serverSync().data.project.find((p) => p.id === id)
const root = meta?.worktree
if (!root) return
@ -631,7 +631,7 @@ export default function Layout(props: ParentProps) {
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = serverSync.child(dir, { bootstrap: true })
const [dirStore] = serverSync().child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
@ -683,10 +683,10 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
route()
serverSDK.url
serverSDK().url
prefetchToken.value += 1
clearSessionPrefetchInflight(serverSDK.scope)
clearSessionPrefetchInflight(serverSDK().scope)
prefetchQueues.clear()
})
@ -730,17 +730,17 @@ export default function Layout(props: ParentProps) {
}
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
const [store, setStore] = serverSync().child(directory, { bootstrap: false })
return runSessionPrefetch({
scope: serverSDK.scope,
scope: serverSDK().scope,
directory,
sessionID,
task: (rev) =>
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
retry(() => serverSDK().client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@ -755,9 +755,9 @@ export default function Layout(props: ParentProps) {
}
if (stale.length > 0) {
clearSessionPrefetch(serverSDK.scope, directory, stale)
clearSessionPrefetch(serverSDK().scope, directory, stale)
for (const id of stale) {
serverSync.todo.set(id, undefined)
serverSync().todo.set(id, undefined)
}
}
@ -767,7 +767,7 @@ export default function Layout(props: ParentProps) {
sorted,
)
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK().scope, directory, sessionID, rev)) return
batch(() => {
if (stale.length > 0) {
@ -779,7 +779,7 @@ export default function Layout(props: ParentProps) {
}
setStore("message", sessionID, reconcile(merged, { key: "id" }))
setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
setSessionPrefetch({ scope: serverSDK().scope, directory, sessionID, ...meta })
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
@ -822,9 +822,9 @@ export default function Layout(props: ParentProps) {
const directory = session.directory
if (!directory) return
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = serverSync().child(directory, { bootstrap: false })
const cached = untrack(() => {
const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
const info = getSessionPrefetch(serverSDK().scope, directory, session.id)
return shouldSkipSessionPrefetch({
message: store.message[session.id] !== undefined,
info,
@ -927,7 +927,7 @@ export default function Layout(props: ParentProps) {
if (!target) return
// warm up child store to prevent flicker
serverSync.child(target.worktree)
serverSync().child(target.worktree)
void openProject(target.worktree)
}
@ -936,7 +936,7 @@ export default function Layout(props: ParentProps) {
const target = projects[index]
if (!target) return
serverSync.child(target.worktree)
serverSync().child(target.worktree)
void openProject(target.worktree)
}
@ -965,12 +965,12 @@ export default function Layout(props: ParentProps) {
}
async function archiveSession(session: Session) {
const [store, setStore] = serverSync.child(session.directory)
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK.client.session.update({
await serverSDK().client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
@ -1241,11 +1241,11 @@ export default function Layout(props: ParentProps) {
)
if (known) return known[0]
const [child] = serverSync.child(directory, { bootstrap: false })
const [child] = serverSync().child(directory, { bootstrap: false })
const id = child.project
if (!id) return directory
const meta = serverSync.data.project.find((item) => item.id === id)
const meta = serverSync().data.project.find((item) => item.id === id)
return meta?.worktree ?? directory
}
@ -1293,8 +1293,8 @@ export default function Layout(props: ParentProps) {
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK.client.worktree
.list({ directory: root })
const listed = await serverSDK()
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
@ -1302,14 +1302,14 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = serverSync.child(target.directory, { bootstrap: false })
const [data] = serverSync().child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await serverSDK.client.session
.get({ sessionID: target.id })
const resolved = await serverSDK()
.client.session.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
if (!resolved?.directory) return false
@ -1328,7 +1328,7 @@ export default function Layout(props: ParentProps) {
}
const latest = latestRootSession(
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
@ -1339,8 +1339,8 @@ export default function Layout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await serverSDK.client.session
.list({ directory: item })
session: await serverSDK()
.client.session.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
})),
@ -1402,11 +1402,11 @@ export default function Layout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
return
}
serverSync.project.meta(project.worktree, { name })
serverSync().project.meta(project.worktree, { name })
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
@ -1495,8 +1495,8 @@ export default function Layout(props: ParentProps) {
setBusy(directory, true)
const result = await serverSDK.client.worktree
.remove({ directory: root, worktreeRemoveInput: { directory } })
const result = await serverSDK()
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
@ -1514,7 +1514,7 @@ export default function Layout(props: ParentProps) {
clearLastProjectSession(root)
}
serverSync.set(
serverSync().set(
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === root)
@ -1553,8 +1553,8 @@ export default function Layout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK.client.session
.list({ directory })
const sessions: Session[] = await serverSDK()
.client.session.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
@ -1562,12 +1562,14 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
serverSDK.scope,
serverSDK().scope,
)
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
await serverSDK()
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK.client.worktree
.reset({ directory: root, worktreeResetInput: { directory } })
const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
@ -1588,8 +1590,8 @@ export default function Layout(props: ParentProps) {
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK.client.session
.update({
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
@ -1629,8 +1631,8 @@ export default function Layout(props: ParentProps) {
})
onMount(() => {
serverSDK.client.vcs
.status({ directory: props.directory })
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
@ -1688,8 +1690,8 @@ export default function Layout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await serverSDK.client.session
.list({ directory: props.directory })
const sessions = await serverSDK()
.client.session.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
@ -1697,8 +1699,8 @@ export default function Layout(props: ParentProps) {
}
onMount(() => {
serverSDK.client.vcs
.status({ directory: props.directory })
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
@ -1831,7 +1833,7 @@ export default function Layout(props: ParentProps) {
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
void serverSync.project.loadSessions(directory)
void serverSync().project.loadSessions(directory)
}
loadedSessionDirs.clear()
@ -1876,7 +1878,7 @@ export default function Layout(props: ParentProps) {
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
@ -1928,8 +1930,8 @@ export default function Layout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await serverSDK.client.worktree
.create({ directory: project.worktree })
const created = await serverSDK()
.client.worktree.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
showToast({
@ -1948,7 +1950,7 @@ export default function Layout(props: ParentProps) {
const root = pathKey(local)
setBusy(created.directory, true)
WorktreeState.pending(serverSDK.scope, created.directory)
WorktreeState.pending(serverSDK().scope, created.directory)
setStore("workspaceExpanded", key, true)
if (key !== created.directory) {
setStore("workspaceExpanded", created.directory, true)
@ -1962,7 +1964,7 @@ export default function Layout(props: ParentProps) {
return [created.directory, ...next]
})
serverSync.child(created.directory)
serverSync().child(created.directory)
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
}
@ -2067,7 +2069,7 @@ export default function Layout(props: ParentProps) {
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
})
const homedir = createMemo(() => serverSync.data.path.home)
const homedir = createMemo(() => serverSync().data.path.home)
return (
<div

View file

@ -14,7 +14,7 @@ export function useSessionTabAvatarState(
const permission = usePermission()
const hasPermissions = createMemo(() => {
if (!active()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
const [store] = globalSync().child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
@ -23,7 +23,7 @@ export function useSessionTabAvatarState(
const loading = createMemo(() => {
if (!active()) return false
if (hasPermissions()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
const [store] = globalSync().child(directory(), { bootstrap: false })
return store.session_working(sessionId())
})
return { unread, loading }

View file

@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = serverSync().child(directory, { bootstrap: false })
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
}),
)
@ -149,7 +149,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const serverSync = useServerSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = serverSync.child(props.session.directory)
const [sessionStore] = serverSync().child(props.session.directory)
const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
return !permission.autoResponds(item, props.session.directory)

View file

@ -294,23 +294,23 @@ export const SortableProject = (props: {
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = serverSync.child(directory, { bootstrap: false })
const [data] = serverSync().child(directory, { bootstrap: false })
const kind =
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
return `${kind} : ${name}`
}
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
const isWorking = createMemo(() =>
dirs().some((directory) => {
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = serverSync().child(directory, { bootstrap: false })
return Object.keys(store.session_status).some((id) => store.session_working(id))
}),
)
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const workspaceSessions = (directory: string) => {
const [data] = serverSync.child(directory, { bootstrap: false })
const [data] = serverSync().child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
const tile = () => (

View file

@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
const directory = props.activeWorkspace()
if (!directory) return
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
@ -303,7 +303,7 @@ export const SortableWorkspace = (props: {
const queryOptions = useQueryOptions()
const language = useLanguage()
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
@ -321,14 +321,14 @@ export const SortableWorkspace = (props: {
const boot = createMemo(() => open() || active())
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory)))
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const loading = () => fetching() > 0 && count() === 0
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.directory)
await serverSync().project.loadSessions(props.directory)
}
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
createEffect(() => {
if (!boot()) return
serverSync.child(props.directory, { bootstrap: true })
serverSync().child(props.directory, { bootstrap: true })
})
return (
@ -450,18 +450,18 @@ export const LocalWorkspace = (props: {
const queryOptions = useQueryOptions()
const language = useLanguage()
const workspace = createMemo(() => {
const [store, setStore] = serverSync.child(props.project.worktree)
const [store, setStore] = serverSync().child(props.project.worktree)
return { store, setStore }
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const count = createMemo(() => sessions()?.length ?? 0)
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree)))
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.project.worktree)
await serverSync().project.loadSessions(props.project.worktree)
}
return (

View file

@ -30,8 +30,8 @@ export default function NewSessionPage() {
const newSessionWorktree = createMemo(() => {
if (store.worktree === "create") return "create"
const project = sync.project
if (project && sdk.directory !== project.worktree) return sdk.directory
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
})

View file

@ -268,10 +268,10 @@ export default function Page() {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const canReview = createMemo(() => !!sync.project)
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
const canReview = createMemo(() => !!sync().project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
@ -283,21 +283,21 @@ export default function Page() {
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => {
const id = params.id
if (!id) return true
return sync.data.message[id] !== undefined
return sync().data.message[id] !== undefined
})
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.more(id)
return sync().session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.loading(id)
return sync().session.history.loading(id)
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@ -357,7 +357,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
@ -404,16 +404,16 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const nogit = createMemo(() => {
const project = sync().project
return !!project && project.vcs !== "git"
})
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
const project = sync().project
const vcs = sync().data.vcs
if (project?.vcs === "git") list.push("git")
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
list.push("branch")
}
list.push("turn")
@ -429,19 +429,20 @@ export default function Page() {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const vcsKey = createMemo(
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
() =>
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = wantsReview() && sync.project?.vcs === "git"
const enabled = wantsReview() && sync().project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
queryFn: mode
? () =>
sdk.client.vcs
.diff({ mode })
sdk()
.client.vcs.diff({ mode })
.then((result) => list(result.data))
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
@ -466,8 +467,8 @@ export default function Page() {
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sdk.directory !== project.worktree) return sdk.directory
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
})
@ -529,11 +530,11 @@ export default function Page() {
}
function upsert(next: Project) {
const list = serverSync.data.project
sync.set("project", next.id)
const list = serverSync().data.project
sync().set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
serverSync.set(
serverSync().set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
@ -541,14 +542,14 @@ export default function Page() {
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
serverSync.set("project", [...list, next])
serverSync().set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
mutationFn: () => sdk().client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
@ -593,7 +594,7 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
const [sessionSync] = createResource(
() => [sdk.directory, params.id] as const,
() => [sdk().directory, params.id] as const,
([directory, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
@ -601,11 +602,11 @@ export default function Page() {
refreshTimer = undefined
if (!id) return
const cached = untrack(() => sync.data.message[id] !== undefined)
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(serverSDK.scope, directory, id)
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
@ -616,12 +617,12 @@ export default function Page() {
refreshTimer = undefined
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
if (stale) void sync().session.sync(id, { force: true })
})
}, 0)
})
return sync.session.sync(id)
return sync().session.sync(id)
},
)
@ -630,9 +631,9 @@ export default function Page() {
() => {
const id = params.id
return [
sdk.directory,
sdk().directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
@ -643,15 +644,17 @@ export default function Page() {
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
const cached = untrack(
() => sync().data.todo[id] !== undefined || serverSync().data.session_todo[id] !== undefined,
)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
if (sdk().directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
void sync().session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
@ -684,7 +687,7 @@ export default function Page() {
),
)
const stopVcs = sdk.event.listen((evt) => {
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
@ -827,7 +830,7 @@ export default function Page() {
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
() => sync().data.session_status[params.id ?? ""]?.type,
(next, prev) => {
if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs()
@ -1098,10 +1101,10 @@ export default function Page() {
if (!id) return
if (!wantsReview()) return
if (sync.data.session_diff[id] !== undefined) return
if (sync.status === "loading") return
if (sync().data.session_diff[id] !== undefined) return
if (sync().status === "loading") return
void sync.session.diff(id)
void sync().session.diff(id)
})
createEffect(
@ -1116,14 +1119,14 @@ export default function Page() {
const id = params.id
if (!id) return
if (!untrack(() => sync.data.session_diff[id] !== undefined)) return
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
diffFrame = requestAnimationFrame(() => {
diffFrame = undefined
diffTimer = window.setTimeout(() => {
diffTimer = undefined
if (sessionKey() !== key) return
void sync.session.diff(id, { force: true })
void sync().session.diff(id, { force: true })
}, 0)
})
},
@ -1133,10 +1136,10 @@ export default function Page() {
let treeDir: string | undefined
createEffect(() => {
const dir = sdk.directory
const dir = sdk().directory
if (!isDesktop()) return
if (!layout.fileTree.opened()) return
if (sync.status === "loading") return
if (sync().status === "loading") return
fileTreeTab()
const refresh = treeDir !== dir
@ -1146,7 +1149,7 @@ export default function Page() {
createEffect(
on(
() => sdk.directory,
() => sdk().directory,
() => {
const tab = activeFileTab()
if (!tab) return
@ -1249,7 +1252,7 @@ export default function Page() {
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
userScrolled: autoScroll.userScrolled,
scroller: () => scroller,
onBeforeLoad: () => captureHistoryAnchor(),
@ -1295,8 +1298,8 @@ export default function Page() {
)
const draft = (id: string) =>
extractPromptFromParts(sync.data.part[id] ?? [], {
directory: sdk.directory,
extractPromptFromParts(sync().data.part[id] ?? [], {
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
})
@ -1319,7 +1322,7 @@ export default function Page() {
}
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
sync.set("session", (list) => {
sync().set("session", (list) => {
const idx = list.findIndex((item) => item.id === next.id)
if (idx < 0) return list
const out = list.slice()
@ -1328,7 +1331,7 @@ export default function Page() {
})
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
sync.set("session", (list) => {
sync().set("session", (list) => {
const idx = list.findIndex((item) => item.id === sessionID)
if (idx < 0) return list
const out = list.slice()
@ -1336,7 +1339,7 @@ export default function Page() {
return out
})
const busy = (sessionID: string) => sync.data.session_working(sessionID)
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const queuedFollowups = createMemo(() => {
const id = params.id
@ -1359,11 +1362,11 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
serverSync,
client: sdk().client,
sync: sync(),
serverSync: serverSync(),
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
optimisticBusy: item.sessionDirectory === sdk().directory,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
@ -1421,7 +1424,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@ -1453,7 +1456,11 @@ export default function Page() {
}
const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
busy(sessionID)
? sdk()
.client.session.abort({ sessionID })
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
@ -1465,7 +1472,7 @@ export default function Page() {
prompt.set(value)
})
await halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then(() => sdk().client.session.revert(input))
.then((result) => {
if (result.data) merge(result.data)
})
@ -1498,9 +1505,9 @@ export default function Page() {
})
const task = !next
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sdk().client.session.revert({
sessionID,
messageID: next.id,
}),
@ -1588,7 +1595,7 @@ export default function Page() {
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),

View file

@ -1,6 +1,6 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { useLayout } from "@/context/layout"
import { PromptInput } from "@/components/prompt-input"
@ -18,6 +18,17 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { createQuery } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
export function SessionComposerRegion(props: {
state: SessionComposerState
@ -54,10 +65,70 @@ export function SessionComposerRegion(props: {
const language = useLanguage()
const route = useSessionKey()
const sync = useSync()
const sdk = useSDK()
const queryOptions = useQueryOptions()
const local = useLocal()
const providers = useProviders()
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
layout.projects.open(worktree)
server.projects.touch(worktree)
if (search.draftId) {
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
return
}
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
if (!server.current) return
pickDirectory({
server: server.current,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
if (directory) selectProject(directory)
},
})
}
const controls = createMemo(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: settings.visibility.customAgents(),
select: local.agent.set,
},
model: {
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: layout.projects.list(),
directory: sdk().directory,
select: selectProject,
add: addProject,
},
session: {
id: route.params.id,
tabs: layout.tabs(route.sessionKey),
reviewPanel: view.reviewPanel,
},
newLayoutDesigns: settings.general.newLayoutDesigns(),
}))
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
const parentID = createMemo(() => info()?.parentID)
const child = createMemo(() => !!parentID())
const showComposer = createMemo(() => !props.state.blocked() || child())
@ -263,6 +334,7 @@ export function SessionComposerRegion(props: {
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
controls={controls()}
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}

View file

@ -32,12 +32,12 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const permission = usePermission()
const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
return !permission.autoResponds(item, sdk.directory)
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
return !permission.autoResponds(item, sdk().directory)
})
})
@ -50,14 +50,14 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return serverSync.data.session_todo[id] ?? []
return serverSync().data.session_todo[id] ?? []
})
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const live = createMemo(() => sync.data.session_working(params.id ?? "") || blocked())
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({
responding: undefined as string | undefined,
@ -78,8 +78,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
if (store.responding === perm.id) return
setStore("responding", perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
sdk()
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@ -111,8 +111,8 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const clear = () => {
const id = params.id
if (!id) return
serverSync.todo.set(id, [])
sync.set("todo", id, [])
serverSync().todo.set(id, [])
sync().set("todo", id, [])
}
createEffect(

View file

@ -64,7 +64,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
@ -209,7 +209,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@ -221,7 +221,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},

View file

@ -281,7 +281,7 @@ export function MessageTimeline(props: {
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@ -305,10 +305,10 @@ export function MessageTimeline(props: {
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
return sync().data.session_status[id] ?? idle
})
const working = createMemo(() => sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
@ -347,25 +347,25 @@ export function MessageTimeline(props: {
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync.session.get(id)
return sync().session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
@ -664,14 +664,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id, directory: sdk().directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id, directory: sdk().directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@ -679,9 +679,9 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk.client.session.update({ sessionID: input.id, title: input.title }),
sdk().client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync.set(
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
@ -731,8 +731,8 @@ export function MessageTimeline(props: {
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync.data.message[id] !== undefined) return
void sync.session.sync(id)
if (sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
@ -780,25 +780,25 @@ export function MessageTimeline(props: {
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
const session = sync().session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
sync.session.evict(sessionID)
sync().session.evict(sessionID)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] })
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
})
.catch((err) => {
showToast({
@ -809,15 +809,15 @@ export function MessageTimeline(props: {
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
const session = sync().session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
const result = await sdk()
.client.session.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
@ -831,7 +831,7 @@ export function MessageTimeline(props: {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync.data.session) {
for (const item of sync().data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
@ -859,16 +859,16 @@ export function MessageTimeline(props: {
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync.set(
sync().set(
produce((draft) => {
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
for (const id of removed) {
sync.session.evict(id)
sync().session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] })
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
@ -880,7 +880,7 @@ export function MessageTimeline(props: {
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)

View file

@ -53,8 +53,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const layout = useLayout()
const readFile = async (path: string) => {
return sdk.client.file
.read({ path })
return sdk()
.client.file.read({ path })
.then((x) => x.data)
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })

View file

@ -51,7 +51,7 @@ export function useUsageExceededDialogs() {
)
onCleanup(
sdk.event.on("session.status", (evt) => {
sdk().event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status

View file

@ -52,7 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const info = () => {
const id = params.id
if (!id) return
return sync.session.get(id)
return sync().session.get(id)
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
@ -73,7 +73,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const messages = () => {
const id = params.id
if (!id) return []
return sync.data.message[id] ?? []
return sync().data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
@ -122,8 +122,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const isAutoAcceptActive = () => {
const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@ -175,8 +175,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
const url = await sdk.client.session
.share({ sessionID })
const url = await sdk()
.client.session.share({ sessionID })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
@ -195,8 +195,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
await sdk.client.session
.unshare({ sessionID })
await sdk()
.client.session.unshare({ sessionID })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
@ -263,12 +263,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
? permission.isAutoAccepting(sessionID, sdk().directory)
: permission.isAutoAcceptingDirectory(sdk().directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
@ -283,18 +283,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
if (sync.data.session_working(params.id ?? "")) {
await sdk.client.session.abort({ sessionID }).catch(() => {})
if (sync().data.session_working(params.id ?? "")) {
await sdk()
.client.session.abort({ sessionID })
.catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
await sdk().client.session.revert({ sessionID, messageID: message.id })
const parts = sync().data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
const restored = extractPromptFromParts(parts, { directory: sdk().directory })
prompt.set(restored)
}
@ -311,14 +313,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
await sdk().client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
await sdk().client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
@ -336,7 +338,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
await sdk.client.session.summarize({
await sdk().client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@ -350,7 +352,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.4",
"version": "1.17.7",
"type": "module",
"license": "MIT",
"bin": {

View file

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

View file

@ -47,6 +47,7 @@ import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter"
import { createProviderBudgetTracker } from "./providerBudgetTracker"
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
@ -132,6 +133,10 @@ export async function handler(
const modelTpmLimits = await modelTpmLimiter?.check()
const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers)
const modelTpsLimits = await modelTpsLimiter?.check()
const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
)
const providerBudgetUsage = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@ -145,12 +150,16 @@ export async function handler(
stickyProvider,
modelTpmLimits,
modelTpsLimits,
providerBudgetUsage,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({
provider: providerInfo.id,
"provider.model": providerInfo.model,
...(providerBudgetUsage?.[providerInfo.id]
? { "provider.budget_usage": providerBudgetUsage?.[providerInfo.id] }
: {}),
})
const startTimestamp = Date.now()
@ -257,6 +266,7 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@ -317,6 +327,7 @@ export async function handler(
timestampLastByte,
usageInfo,
)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@ -483,6 +494,7 @@ export async function handler(
stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudgetUsage: Record<string, number> | undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
@ -505,6 +517,12 @@ export async function handler(
const providers = allProviders
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetMode !== "fill") return true
const budget = zenData.providers[provider.id]?.budget
if (budget === undefined) return false
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0

View file

@ -0,0 +1,45 @@
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis"
export function createProviderBudgetTracker(
providers: {
id: string
budget?: number
budgetContribution?: number
budgetMode?: "always" | "fill"
}[],
) {
const tracked = providers.filter(
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
)
if (tracked.length === 0) return undefined
const interval = new Date()
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
const redis = getRedis()
const keys = Object.fromEntries(
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
)
return {
check: async () => {
const ids = tracked.filter((provider) => provider.budgetMode === "fill").map((provider) => provider.id)
if (ids.length === 0) return {}
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
return Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
},
track: async (provider: string, costInCent: number) => {
const config = tracked.find((item) => item.id === provider)
if (!config) return
if (config.budgetContribution === undefined) return
const cost = centsToMicroCents(costInCent * config.budgetContribution)
if (cost <= 0) return
const pipeline = redis.pipeline()
pipeline.incrby(keys[provider], cost)
pipeline.expire(keys[provider], 120)
await pipeline.exec()
},
}
}

View file

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

View file

@ -37,6 +37,8 @@ export namespace ZenData {
priority: z.number().optional(),
tpmLimit: z.number().optional(),
tpsGoal: z.number().optional(),
budgetMode: z.enum(["always", "fill"]).optional(),
budgetContribution: z.number().optional(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
@ -54,6 +56,7 @@ export namespace ZenData {
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
budget: z.number().optional(),
})
const ModelsSchema = z.object({

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.4",
"version": "1.17.7",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.4",
"version": "1.17.7",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",

View file

@ -10,8 +10,7 @@ import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.Info
@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
}
type Data = {
@ -96,26 +90,17 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
const credential = active.get(IntegrationSchema.ID.make(provider.id))
if (!credential) return provider
const body = { ...provider.request.body }
if (credential.value.type === "key") {
body.apiKey = credential.value.key
Object.assign(body, credential.value.metadata ?? {})
}
if (credential.value.type === "oauth") body.apiKey = credential.value.access
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
return !integration
}
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
@ -203,18 +188,16 @@ export const layer = Layer.effect(
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (!policy.hasStatements()) return
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {})
}),
})
const active = Effect.fn("CatalogV2.active")(function* () {
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
@ -233,18 +216,23 @@ export const layer = Layer.effect(
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return project(record.provider, yield* active())
return record.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
const credentials = yield* active()
return Array.fromIterable(state.get().providers.values()).map((record) =>
project(record.provider, credentials),
)
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return (yield* result.provider.all()).filter((provider) => provider.enabled)
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
)
}),
},
@ -253,33 +241,32 @@ export const layer = Layer.effect(
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model, project(record.provider, yield* active()))
return projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => {
const provider = project(record.provider, credentials)
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
return (yield* result.model.all()).filter(
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (Option.isSome(provider) && provider.value.enabled !== false) {
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
}
@ -295,11 +282,11 @@ export const layer = Layer.effect(
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active())
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
}
const candidates = pipe(
@ -327,7 +314,7 @@ export const layer = Layer.effect(
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => resolve(item.model, provider)),
Array.map((item) => projectModel(item.model, provider)),
Array.head,
)
}
@ -348,6 +335,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)

View file

@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
),
)
yield* integrationTransform((integrations) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id)
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.env !== undefined) provider.env = [...item.env]
provider.enabled = { via: "custom", data: {} }
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)

View file

@ -46,6 +46,8 @@ export interface Interface {
readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
@ -99,6 +101,10 @@ export const layer = Layer.effect(
return credential ? [credential] : []
})
}),
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
id: ID.create(),

View file

@ -29,15 +29,16 @@ export const When = Schema.Struct({
}).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.TextPrompt" })
export type TextPrompt = typeof TextPrompt.Type
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
}),
),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: Schema.optional(Schema.String),
}) {}
}).annotate({ identifier: "Integration.KeyMethod" })
export type KeyMethod = typeof KeyMethod.Type
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.Array(Schema.String),
}) {}
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
@ -197,7 +202,11 @@ export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]>
readonly connect: {
readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@ -218,6 +227,13 @@ export interface Interface {
/** User-facing label for the credential created on completion. */
readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
}
readonly attempt: {
/** Returns the current state of an OAuth attempt. */
@ -326,23 +342,32 @@ export const locationLayer = Layer.effect(
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map(
(credential) =>
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
)
const connected = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map(
(name, index) =>
new IntegrationConnection.EnvInfo({
type: "env",
name,
}),
)
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({
id: entry.ref.id,
@ -382,6 +407,7 @@ export const locationLayer = Layer.effect(
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
: exit.value,
})
yield* events.publish(Event.Updated, {})
}
yield* close(result.scope)
})
@ -421,8 +447,21 @@ export const locationLayer = Layer.effect(
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connect: {
key: Effect.fn("Integration.connect.key")(function* (input) {
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
.integrations.get(input.integrationID)
@ -433,8 +472,9 @@ export const locationLayer = Layer.effect(
label: input.label,
value: new Credential.Key({ type: "key", key: input.key }),
})
yield* events.publish(Event.Updated, {})
}),
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
@ -474,6 +514,14 @@ export const locationLayer = Layer.effect(
time,
})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
yield* credentials.update(credentialID, updates)
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
yield* credentials.remove(credentialID)
yield* events.publish(Event.Updated, {})
}),
},
attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {

View file

@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
import { Schema } from "effect"
import { Credential } from "../credential"
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"),
id: Credential.ID,
label: Schema.String,
}) {}
}).annotate({ identifier: "Connection.CredentialInfo" })
export type CredentialInfo = typeof CredentialInfo.Type
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
}) {}
}).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type"))

View file

@ -1,6 +1,6 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,

View file

@ -22,7 +22,6 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
@ -99,7 +98,6 @@ export const layer = Layer.effect(
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)

View file

@ -1,22 +0,0 @@
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
const key = item.provider.env.find((env) => process.env[env])
if (!key) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "env",
name: key,
}
})
}
}),
}
}),
})

View file

@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: new Integration.KeyMethod({
type: "key",
}),
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: new Integration.EnvMethod({
type: "env",
names: [...item.env],
}),
method: { type: "env", names: [...item.env] },
})
}
})
@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.env = [...item.env]
provider.api = item.npm
? {
type: "aisdk",

View file

@ -1,13 +1,16 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.enabled === false) continue
if (item.provider.disabled) continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue

View file

@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
export const browser = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: browserMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
@ -89,11 +89,11 @@ export const browser = {
export const headless = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: headlessMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(

View file

@ -1,20 +1,20 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpencodePlugin = PluginV2.define({
id: PluginV2.ID.make("opencode"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.request.body.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "credential"),
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"

View file

@ -70,14 +70,17 @@ export const SnowflakeCortexPlugin = PluginV2.define({
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const pat =
process.env.SNOWFLAKE_CORTEX_PAT ?? (typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
(typeof evt.options.token === "string" ? evt.options.token : undefined) ??
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible({
...evt.options,
...(pat ? { apiKey: pat } : {}),
...(token ? { apiKey: token } : {}),
fetch: cortexFetch(upstream) as typeof fetch,
} as any)
}),

View file

@ -245,6 +245,7 @@ export const layer = Layer.effect(
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,

View file

@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@ -48,22 +47,7 @@ export type Request = typeof Request.Type
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
id: ID,
name: Schema.String,
enabled: Schema.Union([
Schema.Literal(false),
Schema.Struct({
via: Schema.Literal("env"),
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("credential"),
credentialID: Credential.ID,
}),
Schema.Struct({
via: Schema.Literal("custom"),
data: Schema.Record(Schema.String, Schema.Any),
}),
]),
env: Schema.String.pipe(Schema.Array),
disabled: Schema.Boolean.pipe(Schema.optional),
api: Api,
request: Request,
}) {
@ -71,8 +55,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
return new Info({
id: providerID,
name: providerID,
enabled: false,
env: [],
api: {
type: "native",
settings: {},

View file

@ -2,22 +2,27 @@ export * as Pty from "./pty"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
const EXITED_LIMIT = 25
const pty = lazy(() => import("#pty"))
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
type Subscriber = {
readonly onData: (chunk: string) => void
readonly onEnd: (event: { exitCode?: number }) => void
active: boolean
detached: boolean
pending: string[]
end?: { exitCode?: number }
}
type Active = {
@ -26,22 +31,10 @@ type Active = {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
subscribers: Map<object, Subscriber>
listeners: Disp[]
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
@ -51,6 +44,8 @@ export const Info = Schema.Struct({
status: Schema.Literals(["running", "exited"]),
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
pid: NonNegativeInt,
// Present once status is "exited".
exitCode: Schema.optional(NonNegativeInt),
}).annotate({ identifier: "Pty" })
export type Info = Types.DeepMutable<typeof Info.Type>
@ -65,14 +60,6 @@ export const CreateInput = Schema.Struct({
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export type PreparedCreate = {
readonly command: string
readonly args: string[]
readonly cwd: string
readonly title?: string
readonly env: Record<string, string>
}
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
@ -85,10 +72,34 @@ export const UpdateInput = Schema.Struct({
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
export type AttachInput = {
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
readonly cursor?: number
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
}
export type Attachment = {
// Retained output from the requested cursor to the current end.
readonly replay: string
// Absolute output cursor after replay.
readonly cursor: number
readonly write: (data: string) => void
// Starts live delivery after the caller has applied replay and cursor metadata.
readonly activate: () => void
readonly detach: () => void
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
@ -99,19 +110,11 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
@ -121,28 +124,41 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
try {
subscriber.onEnd(event)
} catch {}
}
session.subscribers.clear()
}
function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
if (session.info.status === "running") {
try {
if (sock(ws) === sub) ws.close()
session.process.kill()
} catch {}
}
session.subscribers.clear()
notifyEnd(session, {})
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
)
@ -154,12 +170,13 @@ export const layer = Layer.effect(
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return false
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
@ -175,26 +192,34 @@ export const layer = Layer.effect(
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
...process.env,
...input.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
name: "xterm-256color",
cwd: input.cwd,
env: input.env,
}),
)
const info = {
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command: input.command,
args: input.args,
cwd: input.cwd,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
}
const session: Active = {
info,
process: proc,
@ -208,15 +233,15 @@ export const layer = Layer.effect(
session.listeners.push(
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1 || sock(ws) !== key) {
session.subscribers.delete(key)
for (const [token, subscriber] of session.subscribers.entries()) {
if (!subscriber.active) {
subscriber.pending.push(chunk)
continue
}
try {
ws.send(chunk)
subscriber.onData(chunk)
} catch {
session.subscribers.delete(key)
session.subscribers.delete(token)
}
}
session.buffer += chunk
@ -227,12 +252,19 @@ export const layer = Layer.effect(
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(oldest)
}
}),
)
}),
@ -244,66 +276,71 @@ export const layer = Layer.effect(
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
if (input.size) session.process.resize(input.size.cols, input.size.rows)
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* events.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.resize(cols, rows)
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => session.subscribers.delete(sub)
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
onData: input.onData,
onEnd: input.onEnd,
active: false,
detached: false,
pending: [],
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
input.cursor === -1
? end
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
? Math.max(0, input.cursor)
: 0
const replay = (() => {
if (!session.buffer || from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
replay,
cursor: end,
write: (data: string) => {
if (session.info.status === "running") session.process.write(data)
},
onClose: () => {
cleanup()
activate: () => {
if (subscriber.active || subscriber.detached) return
subscriber.active = true
try {
for (const chunk of subscriber.pending) subscriber.onData(chunk)
subscriber.pending.length = 0
if (subscriber.end) subscriber.onEnd(subscriber.end)
} catch {
session.subscribers.delete(token)
}
},
detach: () => {
subscriber.detached = true
subscriber.pending.length = 0
subscriber.end = undefined
session.subscribers.delete(token)
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
return Service.of({ list, get, create, update, remove, write, attach })
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))

View file

@ -1,24 +0,0 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}

View file

@ -0,0 +1,37 @@
export * as PtyProtocol from "./protocol"
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
//
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
const encoder = new TextEncoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
// Replay can be megabytes; send it in bounded frames.
export const REPLAY_CHUNK = 64 * 1024
export function metaFrame(cursor: number) {
const bytes = encoder.encode(JSON.stringify({ cursor }))
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export function chunks(data: string) {
const out: string[] = []
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
return out
}
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
if (typeof message === "string") return message
try {
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
} catch {
return undefined
}
}

View file

@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot"
@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
return connection?.type === "env" ? Auth.config(connection.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@ -83,41 +88,48 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
provider?: ProviderV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => {
const key = apiKey(model, provider)
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
const resolved =
credential?.value.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.value.metadata)
})
const key = apiKey(resolved, connection, credential)
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(model, OpenAIResponses.route)
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(model, AnthropicMessages.route)
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
return Effect.succeed(
withDefaults(model, OpenAICompatibleChat.route)
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
return Effect.fail(
new UnsupportedApiError({
providerID: model.providerID,
modelID: model.id,
api: apiName(model),
providerID: resolved.providerID,
modelID: resolved.id,
api: apiName(resolved),
}),
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant), provider)
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant))
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
@ -130,6 +142,8 @@ export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const boot = yield* PluginBoot.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
@ -140,7 +154,12 @@ export const locationLayer = Layer.effect(
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* fromCatalogModel(
withVariant(selected, session.model?.variant),
connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
)
}),
})
}),

View file

@ -1,10 +1,13 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@opencode-ai/core/util/which"
export * as Shell from "./shell"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { which } from "./util/which"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
@ -47,7 +50,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
@ -56,9 +59,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
if (process.platform !== "win32") return file
const shell = Filesystem.windowsPath(file)
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
@ -76,13 +83,13 @@ function ok(file: string) {
}
function rooted(file: string) {
return path.isAbsolute(Filesystem.windowsPath(file))
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (Filesystem.stat(shell)?.isFile()) return shell
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
@ -99,7 +106,7 @@ function win() {
}
async function unix() {
const text = await Filesystem.readText("/etc/shells").catch(() => "")
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
@ -109,7 +116,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]!
if (process.platform === "win32") return win()[0]
return fallback()
}
@ -119,7 +126,7 @@ export function gitbash() {
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (Filesystem.stat(file)?.size) return file
if (stat(file)?.size) return file
}
function fallback() {
@ -130,7 +137,7 @@ function fallback() {
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
@ -192,24 +199,28 @@ export function args(file: string, command: string, cwd: string) {
return ["-c", command]
}
const defaultPreferred = lazy(() => select(process.env.SHELL))
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
return defaultPreferred()
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
preferred.reset = () => defaultPreferred.reset()
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
return defaultAcceptable()
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
acceptable.reset = () => defaultAcceptable.reset()
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export * as Shell from "./shell"

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@ -25,13 +25,29 @@ const it = testEffect(
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
),
)
describe("CatalogV2", () => {
it.effect("projects active credentials without rebuilding catalog state", () => {
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const first = {
id: Credential.ID.create(),
@ -53,6 +69,7 @@ describe("CatalogV2", () => {
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
)
@ -62,18 +79,44 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: first.id },
request: { body: { apiKey: "first", tenant: "one" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: second.id },
request: { body: { apiKey: "second", tenant: "two" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
})
it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.CATALOG_TEST_API_KEY
process.env.CATALOG_TEST_API_KEY = "secret"
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
yield* integrations.update((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
}),
)
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
else process.env.CATALOG_TEST_API_KEY = previous
}),
),
)
it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@ -292,9 +335,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@ -316,9 +357,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@ -349,12 +388,10 @@ describe("CatalogV2", () => {
yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.enabled = false
provider.disabled = true
})
catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(enabledProvider, () => {})
catalog.model.update(enabledProvider, fallbackModel, () => {})
catalog.model.default.set(disabledProvider, disabledModel)
})

View file

@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
import { it, withEnv } from "../plugin/provider-helper"
function request(headers: Record<string, string>, variant?: string) {
return {
@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
)
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
}),
]),
})
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
names: ["CUSTOM_API_KEY"],
})
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined()
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
),
)
})

View file

@ -1,8 +1,7 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect"
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
@ -25,7 +24,7 @@ function connectionLayer(
}>,
) {
return Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
@ -103,7 +102,7 @@ describe("Integration", () => {
.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize,
}),
)
@ -117,7 +116,7 @@ describe("Integration", () => {
])
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize,
})
})
@ -140,15 +139,20 @@ describe("Integration", () => {
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* integrations.connect.key({
yield* integrations.connection.key({
integrationID,
key: "secret",
label: "Work",
@ -161,6 +165,7 @@ describe("Integration", () => {
value: new Credential.Key({ type: "key", key: "secret" }),
},
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
@ -177,7 +182,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@ -198,7 +203,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({
const attempt = yield* integrations.connection.oauth({
integrationID,
methodID,
inputs: {},
@ -236,7 +241,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@ -249,7 +254,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError,
)
@ -273,7 +278,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
@ -286,7 +291,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete",
@ -310,7 +315,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@ -323,7 +328,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
@ -373,23 +378,28 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.EnvMethod({
method: {
type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
}),
},
}),
)
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
new IntegrationConnection.CredentialInfo({
{ type: "credential", id: rows[0]!.id, label: "Work" },
{
type: "credential",
id: rows[1]!.id,
label: "Personal",
}),
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: rows[1]!.id,
label: "Personal",
})
}).pipe(Effect.provide(projectionLayer)),
(previous) =>
Effect.sync(() => {

View file

@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
)
const layer = Layer.mergeAll(
catalog.pipe(Layer.provide(connections)),
integrations,
@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
new Integration.KeyMethod({ type: "key" }),
new Integration.EnvMethod({
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
}),
},
],
connections: [],
}),

View file

@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
})
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
@ -21,25 +30,23 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const integrations = yield* Integration.Service
yield* integrations.update((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
editor.update(Integration.ID.make("openrouter"), () => {})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(llmgateway.id, (draft) => {
draft.enabled = llmgateway.enabled
draft.api = llmgateway.api
draft.request = llmgateway.request
})
const openrouter = provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
})
catalog.provider.update(openrouter.id, (draft) => {
draft.enabled = openrouter.enabled
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value",
@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("llmgateway", {
@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
}),
)

View file

@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
new Integration.OAuthMethod({
{
id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
new Integration.OAuthMethod({
},
{
id: Integration.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
])
}),
)

View file

@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(integrations))
yield* integrations.update((editor) => {
editor.method.update({
integrationID: Integration.ID.make("opencode"),
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
})
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
),
)
it.effect("uses auth-enabled providers as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
),
)
it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai")

View file

@ -72,6 +72,48 @@ describe("SnowflakeCortexPlugin", () => {
),
)
it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(SnowflakeCortexPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
package: "@ai-sdk/openai-compatible",
options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
},
{},
)
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("falls back to options.token when no Snowflake env token is set", () =>
withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(SnowflakeCortexPlugin)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("snowflake-cortex", "claude-sonnet-4-6"),
package: "@ai-sdk/openai-compatible",
options: {
name: "snowflake-cortex",
baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
token: "options-token",
},
},
{},
)
expect(result.sdk).toBeDefined()
}),
),
)
it.effect("sets includeUsage on the SDK options", () =>
withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
Effect.gen(function* () {

Some files were not shown because too many files have changed in this diff Show more