Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 620b946dbf feat(console): expose Go and Zen usage 2026-08-11 19:08:04 +00:00
96 changed files with 1019 additions and 3279 deletions
+1 -56
View File
@@ -124,66 +124,12 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli-unsigned
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -522,7 +468,6 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
@@ -0,0 +1,68 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
@@ -3,7 +3,6 @@ import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
import { formatServerError } from "@/utils/server-errors"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
@@ -32,14 +31,7 @@ export function useUpdaterAction() {
action,
async run() {
const run = action().run
if (run === "install") {
return platform.updater?.install().catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t, language.t("common.requestFailed")),
})
})
}
if (run === "install") return platform.updater?.install()
if (run !== "check") return
const state = await platform.updater?.check()
-41
View File
@@ -1,41 +0,0 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { brotliCompressSync, constants } from "node:zlib"
export async function buildAppArchive(channel: string) {
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await files(path.join(root, "dist")))
.filter((key) => !key.endsWith(".map"))
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+1 -10
View File
@@ -12,7 +12,6 @@ import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppArchive } from "./app-assets"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -56,21 +55,13 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appArchive,
}
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
+1 -16
View File
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppArchive } from "./app-assets"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -55,20 +54,6 @@ const targets = singleFlag
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appArchive = await buildAppArchive(Script.channel)
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
setup(build) {
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
path: "opencode-app-assets",
namespace: "opencode",
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -95,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
-51
View File
@@ -1,51 +0,0 @@
import { Effect, FileSystem, Option } from "effect"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
})
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
function isText(file: string) {
return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
}
+1 -1
View File
@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("serve", {
description: "Start the v2 API and web server",
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+7 -10
View File
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -44,16 +43,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const incumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -69,7 +68,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
{
app: {
@@ -124,7 +122,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
transform,
).pipe(
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
@@ -146,7 +143,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
-63
View File
@@ -1,63 +0,0 @@
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, FileSystem } from "effect"
import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
import { load, type AssetMap } from "../app-assets"
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { readonly assets?: AssetMap }) {
const fileSystem = yield* FileSystem.FileSystem
const assets = options?.assets
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
const headers = {
"content-type": FSUtil.mimeType(name),
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
: csp(),
"x-content-type-options": "nosniff",
}
return Effect.succeed(
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
)
}
function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
export * as WebUi from "./web-ui"
-4
View File
@@ -1,4 +0,0 @@
declare module "virtual:opencode-app-assets" {
const archive: string
export default archive
}
-70
View File
@@ -1,70 +0,0 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
await writeFile(index, "<html><body>embedded</body></html>")
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"font.woff2": new Uint8Array([0, 1, 2, 255]),
}
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transform = yield* WebUi.handler({ assets })
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
yield* http.serve(
transform(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
}),
)
}),
),
)
const origin = HttpServer.formatAddress(http.address)
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
new Uint8Array([0, 1, 2, 255]),
)
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
})
})
-16
View File
@@ -17,19 +17,6 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(archive: string): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${JSON.stringify(archive)}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -225,14 +212,12 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appArchive: string
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appArchive),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -274,5 +259,4 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
})
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
@@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
@@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
+2 -2
View File
@@ -9310,7 +9310,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/copy": {
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -9536,7 +9536,7 @@
}
}
},
"/api/experimental/project/{projectID}/copy/refresh": {
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
@@ -0,0 +1,75 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
export async function GET(input: APIEvent) {
const token = input.request.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1]
if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 })
const row = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
useBalance: BillingTable.lite,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
goMonthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeSubscribed: LiteTable.timeCreated,
})
.from(KeyTable)
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.leftJoin(
LiteTable,
and(
eq(LiteTable.workspaceID, KeyTable.workspaceID),
eq(LiteTable.userID, KeyTable.userID),
isNull(LiteTable.timeDeleted),
),
)
.where(and(eq(KeyTable.key, token), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!row) return Response.json({ error: "Unauthorized" }, { status: 401 })
const limits = row.timeSubscribed ? LiteData.getLimits() : undefined
return Response.json({
go:
limits && row.timeSubscribed
? {
useBalance: row.useBalance?.useBalance ?? false,
rolling: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weekly: Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
monthly: Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.goMonthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeSubscribed,
}),
}
: undefined,
zen: {
balance: row.balance / 100_000_000,
monthly: {
usage: (row.monthlyUsage ?? 0) / 100_000_000,
limit: row.monthlyLimit ?? undefined,
},
},
})
}
+1 -2
View File
@@ -25,10 +25,9 @@
},
"imports": {
"#sqlite": {
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.node.ts"
"default": "./src/database/sqlite.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
+8 -36
View File
@@ -51,27 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
? {
providerOptions: {
anthropic: {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
},
},
}
: {}),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -109,13 +88,9 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
const credentials = mapBedrockCredentials(settings)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
@@ -180,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
const region =
typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
@@ -196,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
}
}
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
+2 -2
View File
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
...discovered.filter((item) => path.basename(item) === ".claude"),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
...discovered.filter((item) => path.basename(item) === ".agents"),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
+69 -48
View File
@@ -83,14 +83,8 @@ export function normalize(input: unknown): Result {
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
const nativeReferences = decodeMap(
input.references,
ConfigReference.Entry,
["references"],
diagnostics,
decodeEncoded,
)
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
@@ -100,13 +94,13 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
@@ -116,9 +110,8 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyAgents = mapValues(
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
@@ -137,11 +130,11 @@ export function normalize(input: unknown): Result {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
@@ -154,7 +147,7 @@ export function normalize(input: unknown): Result {
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
@@ -166,14 +159,14 @@ export function normalize(input: unknown): Result {
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
@@ -207,7 +200,7 @@ export function normalize(input: unknown): Result {
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
@@ -216,7 +209,7 @@ export function normalize(input: unknown): Result {
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
@@ -224,8 +217,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
return
}
encoded.skills = [
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
@@ -255,8 +248,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
([key, server]) => setOwn(nativeServers, key, server),
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
@@ -411,13 +404,7 @@ function normalizeExperimental(
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeList(
experimental.policies,
ConfigPolicy.Info,
["experimental", "policies"],
diagnostics,
decodeEncoded,
),
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
@@ -433,7 +420,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
@@ -448,7 +435,7 @@ function normalizeFormatter(
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
@@ -460,7 +447,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
@@ -610,44 +597,78 @@ function decodeProviderList(
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): Record<string, A> {
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
const decoded = decode(schema, raw, [...path, name], diagnostics)
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): A[] {
if (value === undefined) return []
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return []
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
+8 -18
View File
@@ -1,9 +1,8 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
return { db }
@@ -46,7 +42,7 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -55,12 +51,6 @@ export function layer(options: Options = { path: ":memory:" }) {
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
+3 -12
View File
@@ -2,7 +2,6 @@ export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
@@ -21,10 +20,8 @@ export type Migration = {
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
})
continue
}
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
yield* apply.pipe(
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
-5
View File
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -1,254 +0,0 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
type TypeId = typeof TypeId
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
export const supportsTuningPragmas = false
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
// allowlisted for migrations that must relax checking inside a transaction.
export const supportsForeignKeyToggle = false
// Minimal structural types for the Durable Object storage API so this adapter
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
type SqlStorageValue = ArrayBuffer | string | number | null
interface SqlStorageCursor {
readonly columnNames: Array<string>
raw(): IterableIterator<Array<SqlStorageValue>>
toArray(): Array<Record<string, SqlStorageValue>>
}
export interface SqlStorage {
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
}
export interface DurableObjectStorage {
readonly sql: SqlStorage
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
transactionSync<T>(closure: () => T): T
}
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly updateValues: never
}
interface Config {
readonly storage: DurableObjectStorage
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
// transaction SQL can never run. withTransaction is replaced below with a
// DurableObjectStorage.transaction-backed implementation; this service only
// tracks the active transaction connection for statements and nesting checks.
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
"@opencode-ai/core/database/SqliteWorkerdTransaction",
)
const transactionError = (message: string) =>
new SqlError({
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
})
const makeWithTransaction =
(
storage: DurableObjectStorage,
connection: Connection,
semaphore: Semaphore.Semaphore,
): SqlClient.SqlClient["withTransaction"] =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
Effect.withFiber((fiber) => {
const services = fiber.context
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
return Effect.fail(
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
)
const effectWithTxn = Effect.provideContext(
effect,
Context.add(services, WorkerdTransaction, [connection, 0] as const),
)
return semaphore.withPermits(1)(
Effect.callback((resume) => {
let interrupted = false
const promise = storage
.transaction(
(txn) =>
new Promise<void>((resolve) => {
if (interrupted) return resolve()
resume(
Effect.onExit(effectWithTxn, (exit) => {
if (Exit.isFailure(exit)) txn.rollback()
resolve()
// wait for the transaction to complete
return Effect.promise(() => promise)
}),
)
}),
)
.catch((cause) =>
resume(
Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
}),
),
),
)
return Effect.suspend(() => {
interrupted = true
return Effect.promise(() => promise)
})
}),
)
})
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
// mode and always returns integers as numbers. Blobs come back as
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
const cursor = native.sql.exec(query, ...params)
const columns = cursor.columnNames
for (const row of cursor.raw()) {
const record: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) {
const value = row[i]
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
}
yield record
}
}
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () => Array.from(runIterator(query, params)),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () =>
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
transactionService: WorkerdTransaction,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session must route through withTransaction instead.
transactionStatements: false,
},
)
return client
})
// Defends against the shared path-based Database.layer, which passes a
// filename instead of storage when resolved under the workerd condition.
const nativeLayer = (config: Config) =>
config.storage
? Layer.succeed(Sqlite.Native, config.storage)
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
+94 -7
View File
@@ -1,15 +1,102 @@
import { FileFinder } from "@ff-labs/fff-bun"
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import {
FileFinder,
type DirItem,
type DirSearchResult,
type FileItem,
type InitOptions,
type MixedItem,
type MixedSearchResult,
type SearchResult,
} from "@ff-labs/fff-bun"
declare global {
const FFF_LIBC: "gnu" | "musl"
}
const adapter = bind(FileFinder)
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.bun"
+94 -6
View File
@@ -1,12 +1,100 @@
import { bind } from "./fff"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
import type {
DirItem,
DirSearchResult,
FileItem,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
const adapter = bind(FileFinder, "fff unavailable on node runtime")
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export const available = adapter.available
export const create = adapter.create
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder?.isAvailable() ?? false
}
export function create(opts: Init): Result<Picker> {
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.node"
-66
View File
@@ -1,66 +0,0 @@
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
aiMode?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
}
export interface SearchOptions {
currentFile?: string
pageIndex?: number
pageSize?: number
}
export interface File {
relativePath: string
}
export interface Directory {
relativePath: string
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Score {
total: number
}
export interface Search {
items: File[]
scores: Score[]
}
export interface DirSearch {
items: Directory[]
scores: Score[]
}
export interface MixedSearch {
items: Mixed[]
scores: Score[]
}
export interface Picker {
destroy(): void
fileSearch(query: string, options?: SearchOptions): Result<Search>
directorySearch(query: string, options?: SearchOptions): Result<DirSearch>
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearch>
}
export interface Backend {
isAvailable(): boolean
create(options: Init): Result<Picker>
}
export function bind(backend: Backend | undefined, unavailable = "fff unavailable") {
return {
available: () => backend?.isAvailable() ?? false,
create: (options: Init): Result<Picker> =>
backend?.create(options) ?? {
ok: false,
error: unavailable,
},
}
}
+1 -7
View File
@@ -126,12 +126,6 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types.includes("application/pdf") ?? false)
const input = ["text"]
if (image) input.push("image")
if (pdf) input.push("pdf")
const prices = remote.billing?.token_prices
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
@@ -156,7 +150,7 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
body: previous?.body,
capabilities: {
tools: remote.capabilities.supports.tool_calls,
input,
input: image ? ["text", "image"] : ["text"],
output: ["text"],
},
variants: variants(remote, messages),
+70 -110
View File
@@ -158,42 +158,45 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
const prepared = prepareRuntimeModel(model, credential)
if (prepared.unresolved.length > 0)
return Effect.fail(
new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: prepared.unresolved,
}),
)
const resolved = prepared.model
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
@@ -205,107 +208,64 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
const loadAISDK = dependencies?.loadAISDK
if (!loadAISDK) return yield* unsupported(resolved)
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...credential?.metadata,
...configuration,
}) ?? {},
)
const runtime = produce(resolved, (draft) => {
draft.settings = settings
})
})
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return yield* unsupported(resolved)
if (!native) return Effect.fail(unsupported(resolved))
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
})
}
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return produce(model, (draft) => {
const prepared = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
return process.env[name] ?? placeholder
})
})
}
function validateProviderVariables(
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
if (typeof baseURL !== "string") return Effect.succeed(resolved)
const failure = unresolvedProviderVariables(model, baseURL)
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
if (typeof baseURL !== "string") return Effect.succeed(settings)
return prepareProviderURL(model, baseURL).pipe(
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: Array.from(variables),
})
const baseURL = prepared.settings?.baseURL
const unresolved =
typeof baseURL === "string"
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
(name, index, names) => names.indexOf(name) === index,
)
: []
return { model: prepared, unresolved }
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
+1 -5
View File
@@ -52,11 +52,7 @@ export const Plugin = define({
text,
resume: false,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }),
),
)
.pipe(Effect.catch(() => Effect.void))
}),
Effect.forkScoped({ startImmediately: true }),
)
@@ -5,7 +5,6 @@ import { Credential } from "../../credential"
import { Bus } from "../../bus"
import { CopilotModels } from "../../github-copilot/models"
import { App } from "../../app"
import { Agent } from "../../agent"
import { Integration } from "../../integration"
import { Model } from "../../model"
import { define } from "@opencode-ai/plugin/effect/plugin"
@@ -243,10 +242,6 @@ export const GithubCopilotPlugin = define({
yield* ctx.session.hook("http.request", (evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
+1 -1
View File
@@ -30,7 +30,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
integrationID: "exa",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "exa",
@@ -41,7 +41,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
@@ -56,7 +56,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
integrationID: "parallel",
method: { type: "key" },
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "parallel",
+6 -25
View File
@@ -1,7 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Stream } from "effect"
@@ -12,17 +11,14 @@ import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { App } from "../app"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
import type { Info, Ref } from "../model"
import type { Info } from "../model"
import { SessionUsage } from "./usage"
import { PluginHooks } from "../plugin/hooks"
import { Agent } from "../agent"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
@@ -70,18 +66,16 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly config: Settings
readonly hooks: PluginHooks.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
}
@@ -91,12 +85,9 @@ export type ManualInput = {
readonly inputID: SessionMessage.ID
}
type RequiredInput = Omit<AutoInput, "ref">
type Plan = {
readonly session: SessionSchema.Info
readonly model: LanguageModel
readonly ref: Ref
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
@@ -109,7 +100,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface {
readonly required: (input: RequiredInput) => boolean
readonly required: (input: AutoInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
}
@@ -274,13 +265,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(plan.prompt)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -347,7 +331,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: input.model,
ref: input.ref,
cost: input.cost,
reason: "auto",
...content,
@@ -359,7 +342,7 @@ const make = (dependencies: Dependencies) => {
error,
})
})
const required = (input: RequiredInput) => {
const required = (input: AutoInput) => {
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -402,7 +385,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
model: resolved.model,
ref: resolved.ref,
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
@@ -424,13 +406,12 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks })
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node],
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
})
-39
View File
@@ -1,39 +0,0 @@
export * as SessionModelHttp from "./model-http"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import { Effect, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PluginHooks } from "../plugin/hooks"
export const middleware =
(
hooks: PluginHooks.Interface,
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
): NonNullable<StreamOptions["http"]> =>
(request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
...input,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
...input,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
+32 -7
View File
@@ -4,7 +4,8 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
@@ -14,7 +15,6 @@ import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionPromptCacheKey } from "./prompt-cache-key"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
@@ -227,11 +227,36 @@ export const layer = Layer.effect(
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
http: (request, handler) =>
Effect.gen(function* () {
const before = yield* hooks.trigger("session", "http.request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: yield* HttpClientRequest.toWeb(request),
})
let sent = HttpClientRequest.fromWeb(before.request)
if (before.request.body)
sent = HttpClientRequest.bodyUint8Array(
sent,
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
before.request.headers.get("content-type") ?? undefined,
)
const response = yield* handler(sent)
const after = yield* hooks.trigger("session", "http.response", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: before.request,
response: new Response(
[204, 205, 304].includes(response.status)
? null
: yield* Stream.toReadableStreamEffect(response.stream),
{ status: response.status, headers: response.headers },
),
})
return HttpClientResponse.fromWeb(sent, after.response)
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+1 -1
View File
@@ -244,7 +244,7 @@ const layer = Layer.effect(
const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
+3 -24
View File
@@ -1,7 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent"
import { Database } from "../database/database"
@@ -10,11 +9,9 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelHttp } from "./model-http"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
@@ -27,12 +24,11 @@ type Dependencies = {
readonly app: App.Info
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
readonly store: SessionStore.Interface
readonly hooks: PluginHooks.Interface
}
export interface Interface {
@@ -89,13 +85,6 @@ const make = (dependencies: Dependencies) => {
messages: [Message.user(firstUser.text)],
tools: [],
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
.pipe(
Stream.runForEach((event) => {
@@ -146,8 +135,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const hooks = yield* PluginHooks.Service
const title = make({ bus, llm, agents, models, store, app, hooks })
const title = make({ bus, llm, agents, models, store, app })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
})
@@ -157,14 +145,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
llmClient,
Agent.node,
SessionRunnerModel.node,
SessionStore.node,
Database.node,
App.node,
PluginHooks.node,
],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
})
+2 -42
View File
@@ -123,16 +123,6 @@ describe("AISDKNative", () => {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
{
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
"openai.gpt-5.5",
),
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
@@ -144,9 +134,8 @@ describe("AISDKNative", () => {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
region: "eu-west-1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
@@ -163,7 +152,7 @@ describe("AISDKNative", () => {
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
region: "eu-west-1",
providerOptions: { openai: { store: false } },
},
})
@@ -273,35 +262,6 @@ describe("AISDKNative", () => {
})
})
test("maps Vertex Anthropic settings to native Messages", () => {
expect(
map("@ai-sdk/google-vertex/anthropic", {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
headers: { "x-test": "value" },
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
accessToken: "vertex-token",
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
},
headers: { "x-test": "value" },
})
})
test("maps supported xAI settings", () => {
expect(
map("@ai-sdk/xai", {
+4 -4
View File
@@ -1464,13 +1464,13 @@ describe("Config", () => {
])
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
])
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
])
expect(documents.map((document) => document.info.$schema)).toEqual([
"global",
@@ -1483,11 +1483,11 @@ describe("Config", () => {
])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
AbsolutePath.make(globalClaude),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(path.join(directory, ".claude")),
AbsolutePath.make(path.join(root, ".claude")),
AbsolutePath.make(globalAgents),
AbsolutePath.make(path.join(root, ".agents")),
AbsolutePath.make(path.join(directory, ".agents")),
AbsolutePath.make(path.join(root, ".agents")),
"global",
AbsolutePath.make(global),
"outside",
-53
View File
@@ -16,7 +16,6 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -24,8 +23,6 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
@@ -94,25 +91,6 @@ const start = (skills: string[], directory: string) =>
directory,
)
const discover = (directory: string, global: string) =>
Effect.gen(function* () {
const config = yield* Config.Service
return yield* config.entries()
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
]),
),
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
@@ -240,37 +218,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
),
)
it.live("prefers a worktree skill over the parent checkout copy", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const checkout = path.join(tmp.path, "repo")
const worktree = path.join(checkout, ".worktrees", "feature")
const parentSkills = path.join(checkout, ".agents", "skills")
const worktreeSkills = path.join(worktree, ".agents", "skills")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
await write(parentSkills, "review", "Parent checkout")
await write(worktreeSkills, "review", "Worktree")
})
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
const skill = yield* startEntries(entries, worktree)
const review = (yield* skill.list()).find((item) => item.id === "review")
expect(review?.description).toBe("Worktree")
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -84,19 +84,6 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
yield* DatabaseMigration.apply(db)
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
{ name: "session_v2" },
)
}),
)
})
test("applies generic migrations once and records their order", async () => {
await run(
Effect.gen(function* () {
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import os from "os"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
@@ -27,32 +27,8 @@ test("defensively syncs advertised Copilot models", async () => {
max_context_window_tokens: 200000,
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png", "application/pdf"],
},
},
supports: { tool_calls: true, vision: true, reasoning_effort: ["low", "high"] },
},
},
{
model_picker_enabled: true,
id: "vision-only",
name: "Vision only",
version: "vision-only-2026-06-01",
capabilities: {
family: "vision",
limits: {
max_output_tokens: 16384,
max_prompt_tokens: 180000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: { tool_calls: true, vision: true },
supports: { tool_calls: true, reasoning_effort: ["low", "high"] },
},
},
{
@@ -91,8 +67,6 @@ test("defensively syncs advertised Copilot models", async () => {
Model.VariantID.make("low"),
Model.VariantID.make("high"),
])
expect(model?.capabilities.input).toEqual(["text", "image", "pdf"])
expect(models.get(Model.ID.make("vision-only"))?.capabilities.input).toEqual(["text", "image"])
expect(models.get(Model.ID.make("utility"))?.enabled).toBe(false)
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("incomplete"))).toBe(false)
+1 -167
View File
@@ -123,24 +123,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves environment templates before native providers inspect endpoints", () =>
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
settings: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
expect(resolved.route.endpoint).toMatchObject({
baseURL: "https://resource.openai.azure.com/openai/v1",
query: { "api-version": "v1" },
})
}),
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
@@ -170,46 +152,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
withEnv({ AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
providerID: Provider.ID.amazonBedrock,
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(resolved.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
})
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
}),
),
)
it.effect("prefers the configured Mantle region over the environment", () =>
withEnv({ AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
}),
)
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
}),
),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -323,7 +265,7 @@ describe("ModelResolver", () => {
),
)
it.effect("rejects unresolved variables in constructed provider routes", () =>
it.effect("rejects unresolved provider URL variables before route construction", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
@@ -763,57 +705,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
const credential = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "vertex-token",
refresh: "refresh",
expires: Date.now() + 60_000,
})
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
modelID: "claude-sonnet-4-6",
settings: {
location: "eu",
project: "vertex-project",
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
}),
credential,
{
loadPackage: (specifier) => {
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
return Effect.succeed({
model: (modelID, settings) => {
expect(modelID).toBe("claude-sonnet-4-6")
expect(settings).toMatchObject({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
providerOptions: {
anthropic: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
},
})
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
},
})
},
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
},
)
expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
@@ -932,63 +823,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["MISSING_HOST"],
})
}),
),
)
it.effect("rejects unresolved variables before loading native provider packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadPackage: () => Effect.die("Native package loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
+2 -1
View File
@@ -4,6 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { Command } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
@@ -34,7 +35,7 @@ const npmLayer = Layer.succeed(
}),
)
export const PluginTestLayer = LayerNode.compile(
export const PluginTestLayer = AppNodeBuilder.build(
LayerNode.group([
FileSystem.node,
FSUtil.node,
@@ -141,32 +141,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
request: new Request("https://api.githubcopilot.com/chat/completions"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
}),
)
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
request: new Request("https://api.githubcopilot.com/responses"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
}),
)
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
@@ -54,22 +53,6 @@ describe("built-in web search providers", () => {
}),
)
it.effect("registers Firecrawl with the standard key method", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchFirecrawl.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("firecrawl"))).toMatchObject({
id: "firecrawl",
name: "Firecrawl",
methods: [{ type: "key" }, { type: "env", names: ["FIRECRAWL_API_KEY"] }],
})
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -146,9 +129,6 @@ describe("built-in web search providers", () => {
yield* WebSearchParallel.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("parallel"))).toMatchObject({
methods: [{ type: "key" }, { type: "env", names: ["PARALLEL_API_KEY"] }],
})
yield* integrations.connection.key({
integrationID: Integration.ID.make("parallel"),
key: "parallel-secret",
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { BunPlugin } from "bun"
import { join } from "path"
// Pins the #sqlite subpath-import condition gate: bundling database.ts for a
// non-Bun runtime must never reach the static `import "bun:sqlite"` in
// sqlite.bun.ts, which crashes workerd (and any other non-Bun bundle) at
// module load. Bare imports are externalized so only core-relative modules —
// including the #sqlite resolution under test — end up in the bundle.
const externalizeBare: BunPlugin = {
name: "externalize-bare",
setup(build) {
build.onResolve({ filter: /^[^.#/]/ }, (args) => ({ path: args.path, external: true }))
},
}
const bundle = async (conditions: Array<string>) => {
const result = await Bun.build({
// join over URL.pathname: on Windows the latter yields "/C:/..." which
// module resolution rejects.
entrypoints: [join(import.meta.dir, "../src/database/database.ts")],
target: "browser",
conditions,
plugins: [externalizeBare],
throw: false,
})
expect(result.logs).toEqual([])
expect(result.success).toBe(true)
return result.outputs[0].text()
}
// Skipped on Windows: Bun.build inside `bun test` reliably panics Bun 1.3.14
// there ("Internal assertion failure", twice in a row on CI, always at this
// file). The assertions pin platform-independent package.json condition
// resolution, so Linux coverage loses nothing.
describe.skipIf(process.platform === "win32")("sqlite bundle conditions", () => {
test("workerd conditions select the Durable Object driver and never bun:sqlite", async () => {
const output = await bundle(["workerd"])
expect(output).not.toContain("bun:sqlite")
expect(output).not.toContain("node:sqlite")
expect(output).toContain("SqliteWorkerd")
})
test("default conditions fall back to node:sqlite, not bun:sqlite", async () => {
const output = await bundle([])
expect(output).not.toContain("bun:sqlite")
expect(output).toContain("SqliteNode")
})
})
-141
View File
@@ -1,141 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { SqlError } from "effect/unstable/sql/SqlError"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { tempGlobalLayer } from "./fixture/global"
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
// be verified without workerd or Cloudflare runtime dependencies.
const makeFakeStorage = () => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
const storage: DurableObjectStorage = {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
return storage
}
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
describe("sqlite.workerd", () => {
test("executes statements with bindings and maps rows to records", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
}),
)
expect(rows).toEqual([
{ id: 1, name: "one" },
{ id: 2, name: "two" },
])
})
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
}),
)
expect(rows[0].data).toBeInstanceOf(Uint8Array)
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
})
test("withTransaction commits on success and rolls back on failure", async () => {
const storage = makeFakeStorage()
const count = await run(
storage,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
yield* sql
.withTransaction(
Effect.gen(function* () {
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
return yield* Effect.fail("rollback")
}),
)
.pipe(Effect.ignore)
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
}),
)
expect(count[0].count).toBe(1)
})
test("nested withTransaction fails with SqlError", async () => {
const error = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
return yield* sql
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
.pipe(Effect.flip)
}),
)
expect(error).toBeInstanceOf(SqlError)
})
test("boots the full database layer with migrations over injected storage", async () => {
const storage = makeFakeStorage()
const core = await import("@opencode-ai/core/database/database")
await Effect.runPromise(
Effect.scoped(
Layer.build(
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
),
),
)
const names = storage.sql
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.toArray()
.map((row) => row.name)
expect(names).toContain("migration")
expect(names).toContain("session_v2")
})
})
+63 -86
View File
@@ -3,20 +3,17 @@ import { realpathSync } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -30,7 +27,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
@@ -39,7 +35,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = Session.ID.make("ses_shell_tool_test")
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
@@ -127,42 +123,27 @@ const executionNode = makeGlobalNode({
deps: [Bus.node, SessionStore.node],
})
const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [
Config.node,
Environment.node,
LocationMutation.node,
Permission.node,
PluginRuntime.node,
Shell.node,
Tool.node,
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
Global.node,
]),
[
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
],
})
)
const nodes = LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
Global.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Permission.node, permission],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const it = testEffect(layer)
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
@@ -183,6 +164,9 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const timeoutOutputCommand = isWindows
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
: "printf 'before timeout'; sleep 60"
const steadyProgressCommand = isWindows
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
: "printf steady; sleep 3.4"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
@@ -211,56 +195,49 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
return yield* Effect.gen(function* () {
yield* (yield* PluginSupervisor.Service).flush
const registry = yield* Tool.Service
yield* waitForTool(registry, ShellTool.name)
return yield* body(registry)
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
})
describe("ShellTool", () => {
productionIt.live(
"registers and returns real successful output from the active Location",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
// Code Mode receives the declared output schema, including the command output text.
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
it.live("registers and returns real successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
const definition = definitions.find((tool) => tool.name === "shell")
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
// Code Mode receives the declared output schema, including the command output text.
expect(definition?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{
sessionID,
action: "shell",
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
},
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("resolves a relative workdir from the active Location", () =>
@@ -599,7 +576,7 @@ describe("ShellTool", () => {
)
it.live(
"reports shell ID progress once",
"does not repeat shell ID progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -609,7 +586,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const updates: Tool.Metadata[] = []
yield* executeTool(registry, {
...call({ command: helloCommand }, "call-shell-id-progress"),
...call({ command: steadyProgressCommand }, "call-steady-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toHaveLength(1)
+26 -29
View File
@@ -1,14 +1,13 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -25,13 +24,12 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
const childText = "child final response"
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
@@ -94,30 +92,23 @@ const executionNode = makeGlobalNode({
deps: [Bus.node, SessionStore.node],
})
const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
]),
[
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
)
const nodes = LayerNode.group([
Database.node,
Bus.node,
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const it = testEffect(layer)
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
@@ -145,7 +136,7 @@ const withSubagent = (location: Location.Ref) =>
})
describe("SubagentTool", () => {
productionIt.live("registers globally while resolving agents from the caller location", () =>
it.live("registers globally while resolving agents from the caller location", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -159,6 +150,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -194,6 +186,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -236,6 +229,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* executeTool(registry, {
sessionID: parent.id,
@@ -276,6 +270,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const progress: Tool.Metadata[] = []
const settled = yield* executeTool(registry, {
@@ -338,6 +333,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
@@ -375,6 +371,7 @@ describe("SubagentTool", () => {
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "path"
import os from "os"
import { Cause, Effect, Exit } from "effect"
import { testEffect } from "../lib/effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { Global } from "@opencode-ai/util/global"
@@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
// ---------------------------------------------------------------------------
// Tests
+1 -2
View File
@@ -25,7 +25,6 @@ import {
} from "./onboarding"
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import { registerUpdaterIpc } from "./updater-ipc"
import { safeWebContentsURL } from "./window-state"
import {
getLastFocusedWindow,
@@ -284,6 +283,7 @@ const main = Effect.gen(function* () {
setDisplayBackend: async () => undefined,
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
updater,
showUpdater: () => showUpdaterDialog(updater, true),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
@@ -292,7 +292,6 @@ const main = Effect.gen(function* () {
if (setNativeTranslations(bundle)) createMenu(menuDeps)
},
})
registerUpdaterIpc(updater)
registerWslIpcHandlers(wslServers)
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
+19
View File
@@ -20,6 +20,8 @@ import {
setTitlebar,
updateTitlebar,
} from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
import { createDesktopDraftStore } from "./draft-store"
import { nativeT } from "./native-translations"
@@ -44,6 +46,7 @@ type Deps = {
setDisplayBackend: (backend: string | null) => Promise<void> | void
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
updater: UpdaterController
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
@@ -53,6 +56,8 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) {
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)
app.on("before-quit", () => drafts.flush())
app.once("will-quit", () => drafts.close())
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
@@ -75,6 +80,20 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
updaterSubscriptions.set(
id,
deps.updater.subscribe((state) => {
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => deps.updater.check())
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
@@ -1,31 +1,25 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const platform: UpdaterPlatform = {
async checkForUpdate() {
const backend: UpdaterBackend = {
async checkForUpdates() {
calls.push("check")
return "2.0.0"
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
},
async stageUpdate() {
async downloadUpdate() {
calls.push("download")
},
installAndRestart() {
quitAndInstall() {
calls.push("install")
return new Promise<never>(() => {})
},
}
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
platform,
lifecycle: {
async prepareToRestart() {
calls.push("prepare")
},
},
backend,
persistence: {
get: () => ready,
set: (value) => {
@@ -35,6 +29,9 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
ready = undefined
},
},
stop: async () => {
calls.push("stop")
},
})
return { controller, calls, getReady: () => ready }
}
@@ -79,81 +76,36 @@ describe("updater controller", () => {
expect(app.calls).toEqual(["check", "download"])
})
test("starts installing synchronously and coalesces restart requests", async () => {
test("returns to ready when quitAndInstall returns without exiting", async () => {
const app = setup()
await app.controller.start()
const first = app.controller.install()
const second = app.controller.install()
await app.controller.install()
expect(first).toBe(second)
await Promise.resolve()
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(app.calls).toEqual(["check", "download", "stop", "install"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("does not check for updates while installation is in progress", async () => {
test("returns to ready when installation cannot start", async () => {
const app = setup()
await app.controller.start()
void app.controller.install()
await app.controller.check()
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("returns to ready when installation fails", async () => {
const app = setup()
await app.controller.start()
const error = new Error("install failed")
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart: () => Promise.reject(error),
backend: {
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
downloadUpdate: async () => {},
quitAndInstall() {},
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
stop: async () => {
throw new Error("stop failed")
},
})
await failed.start()
await expect(failed.install()).rejects.toThrow("install failed")
await expect(failed.install()).rejects.toThrow("stop failed")
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("allows a state subscriber to retry after installation fails", async () => {
let attempts = 0
let sawInstalling = false
let retry: Promise<void> | undefined
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart() {
attempts++
if (attempts === 1) return Promise.reject(new Error("install failed"))
return new Promise<never>(() => {})
},
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
})
failed.subscribe((state) => {
if (state.status === "installing") sawInstalling = true
if (!sawInstalling || state.status !== "ready" || retry) return
retry = failed.install()
})
await failed.start()
await expect(failed.install()).rejects.toThrow("install failed")
expect(retry).toBeDefined()
expect(attempts).toBe(2)
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
})
+26 -45
View File
@@ -4,14 +4,10 @@ export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterPlatform = {
checkForUpdate(): Promise<string | undefined>
stageUpdate(): Promise<unknown>
installAndRestart(): Promise<never>
}
export type UpdaterLifecycle = {
prepareToRestart(): Promise<void>
export type UpdaterBackend = {
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
downloadUpdate(): Promise<unknown>
quitAndInstall(): void
}
type UpdaterPersistence = {
@@ -23,14 +19,13 @@ type UpdaterPersistence = {
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle
backend: UpdaterBackend
persistence: UpdaterPersistence
stop: () => Promise<void>
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
let installing: Promise<void> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const transition = (next: UpdaterState) => {
@@ -42,21 +37,20 @@ export function createUpdaterController(input: {
const check = () => {
if (!input.enabled) return Promise.resolve(state)
const platform = input.platform
if (!platform) return Promise.resolve(state)
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state)
if (state.status === "ready") return Promise.resolve(state)
if (pending) return pending
pending = (async () => {
transition({ status: "checking" })
const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) {
const result = await input.backend.checkForUpdates()
const version = result?.updateInfo?.version
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await platform.stageUpdate()
await input.backend.downloadUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})()
@@ -69,33 +63,6 @@ export function createUpdaterController(input: {
return pending
}
const install = () => {
if (installing) return installing
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const version = startInstalling(state.version)
installing = restartWithUpdate(version)
return installing
}
const startInstalling = (version: string) => {
transition({ status: "installing", version })
return version
}
const restartWithUpdate = (version: string) =>
prepareAndRestart().catch((error) => {
installing = undefined
transition({ status: "ready", version })
throw error
})
const prepareAndRestart = async () => {
if (!input.platform) throw new Error("Updater is disabled")
await input.lifecycle.prepareToRestart()
await input.platform.installAndRestart()
}
return {
getState: () => state,
subscribe(listener: (state: UpdaterState) => void) {
@@ -109,7 +76,21 @@ export function createUpdaterController(input: {
return check()
},
check,
install,
async install() {
if (state.status !== "ready") throw new Error("Update is not ready to install")
const version = state.version
transition({ status: "installing", version })
await input
.stop()
.then(() => {
input.backend.quitAndInstall()
transition({ status: "ready", version })
})
.catch((error) => {
transition({ status: "ready", version })
throw error
})
},
}
}
-23
View File
@@ -1,23 +0,0 @@
import { app, ipcMain } from "electron"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
export function registerUpdaterIpc(controller: UpdaterController) {
const subscriptions = createUpdaterSubscriptions()
app.once("will-quit", subscriptions.clear)
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
subscriptions.set(
id,
controller.subscribe((state) => {
if (event.sender.isDestroyed()) return subscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => subscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => controller.check())
ipcMain.handle("updater-install", () => controller.install())
}
@@ -1,89 +0,0 @@
import { app, autoUpdater } from "electron"
import pkg from "electron-updater"
import { getLogger } from "./logging"
import { setAppQuitting } from "./windows"
import type { UpdaterPlatform } from "./updater-controller"
const updateClient = pkg.autoUpdater
const restartTimeout = 10_000
export function createUpdaterPlatform(logger: ReturnType<typeof getLogger>): UpdaterPlatform {
configureUpdater(logger)
autoUpdater.on("before-quit-for-update", () => setAppQuitting())
return {
async checkForUpdate() {
const result = await updateClient.checkForUpdates()
if (!result?.isUpdateAvailable) return
return result.updateInfo.version
},
stageUpdate,
installAndRestart: () => installAndRestart(logger),
}
}
function configureUpdater(logger: ReturnType<typeof getLogger>) {
updateClient.logger = logger
updateClient.channel = "latest"
updateClient.allowPrerelease = false
updateClient.allowDowngrade = true
updateClient.autoDownload = false
updateClient.autoInstallOnAppQuit = process.platform === "darwin"
logger.log("auto updater configured", {
channel: updateClient.channel,
allowPrerelease: updateClient.allowPrerelease,
allowDowngrade: updateClient.allowDowngrade,
currentVersion: app.getVersion(),
})
}
function stageUpdate() {
if (process.platform !== "darwin") return updateClient.downloadUpdate()
return new Promise<void>((resolve, reject) => {
const cleanup = () => {
autoUpdater.removeListener("update-downloaded", complete)
updateClient.removeListener("error", fail)
}
const complete = () => {
cleanup()
resolve()
}
const fail = (error: Error) => {
cleanup()
reject(error)
}
autoUpdater.once("update-downloaded", complete)
updateClient.once("error", fail)
void updateClient.downloadUpdate().catch(fail)
})
}
function installAndRestart(logger: ReturnType<typeof getLogger>) {
return new Promise<never>((_resolve, reject) => {
const timeout = setTimeout(() => {
logger.error("update restart did not start")
fail(new Error())
}, restartTimeout)
const started = () => {
clearTimeout(timeout)
autoUpdater.removeListener("before-quit-for-update", started)
}
const fail = (error: Error) => {
clearTimeout(timeout)
autoUpdater.removeListener("before-quit-for-update", started)
updateClient.removeListener("error", fail)
setAppQuitting(false)
reject(error)
}
autoUpdater.once("before-quit-for-update", started)
updateClient.once("error", fail)
try {
updateClient.quitAndInstall()
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
})
}
+35 -4
View File
@@ -1,21 +1,51 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
import { setAppQuitting } from "./windows"
import { nativeT } from "./native-translations"
import { createUpdaterPlatform } from "./updater-platform"
const { autoUpdater } = pkg
const key = "ready"
export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
export function setupAutoUpdater(stop: () => Promise<void>) {
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
autoUpdater.allowPrerelease = false
autoUpdater.allowDowngrade = true
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
logger.log("auto updater configured", {
channel: autoUpdater.channel,
allowPrerelease: autoUpdater.allowPrerelease,
allowDowngrade: autoUpdater.allowDowngrade,
currentVersion: app.getVersion(),
})
const store = getStore("opencode.updater")
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
lifecycle: { prepareToRestart },
backend: {
checkForUpdates: () => autoUpdater.checkForUpdates(),
downloadUpdate: () => autoUpdater.downloadUpdate(),
quitAndInstall: () => {
// quitAndInstall closes all windows before emitting before-quit, so
// flag the quit first to keep window ids persisted for restore.
setAppQuitting()
try {
autoUpdater.quitAndInstall()
} catch (error) {
// The install failed and the app keeps running; clear the flag so
// deliberate window closes prune ids again.
setAppQuitting(false)
throw error
}
},
},
persistence: {
get() {
const value = store.get(key)
@@ -25,6 +55,7 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
stop,
log: (message, data) => logger.log(message, data),
})
}
+13 -79
View File
@@ -150,89 +150,24 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
}
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
/**
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
* may render around, inside, or take over. Paths are absolute and
* dot-separated, and a path contains every path it prefixes — replacing
* `prompt.footer` owns everything under `prompt.footer.*`.
*
* Each slot publishes an input: reactive props passed to every claim render
* targeting it. Inputs carry only what the SDK cannot answer — instance
* identity and client-local state. Paths and their inputs are documented
* API: coarse, few, and kept stable across host refactors.
*/
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "prompt.footer": PromptFooterInput
readonly "prompt.footer.status": PromptFooterInput
readonly "prompt.footer.file": PromptFooterInput
readonly "session.composer.top": { readonly sessionID: string }
readonly "sidebar.content": { readonly sessionID: string }
readonly "prompt.footer.end": {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "session.composer.top": {
readonly sessionID: string
}
readonly "sidebar.content": {
readonly sessionID: string
}
readonly "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotPath = keyof SlotMap
/**
* One contribution to the slot tree. Exactly one placement key names an
* absolute target path:
* - `prepend` / `append`: first/last inside the target's boundary
* - `before` / `after`: siblings adjacent to the target, outside its boundary
* - `replace`: take over the target. The boundary itself survives — siblings
* anchored `before`/`after` it still compose — but the original content and
* every claim inside the boundary are suppressed and recorded, never
* silently dropped. At the same target the last-enabled claim wins; an
* ancestor replacement beats a descendant one regardless of enable order.
*
* A claim aimed at a path the host no longer publishes degrades: additive
* claims append to the nearest surviving ancestor, replacements are
* suppressed. Several claims at one anchor coexist in plugin enable order.
*
* `render` receives the target slot's input, reactively. The `?: never`
* fields make the variants mutually exclusive: a claim with two placement
* keys is a type error, not a silent priority pick.
*/
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
| {
readonly prepend: Path
readonly append?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly append: Path
readonly prepend?: never
readonly before?: never
readonly after?: never
readonly replace?: never
}
| {
readonly before: Path
readonly prepend?: never
readonly append?: never
readonly after?: never
readonly replace?: never
}
| {
readonly after: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly replace?: never
}
| {
readonly replace: Path
readonly prepend?: never
readonly append?: never
readonly before?: never
readonly after?: never
}
)
: never
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
export interface App {
readonly version: string
@@ -459,8 +394,7 @@ export interface UI {
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
/** Claims a place in the slot tree; see SlotClaim. */
readonly slot: (claim: SlotClaim) => () => void
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
}
export interface Context {
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/copy": {
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/api/experimental/project/{projectID}/copy/refresh": {
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+1 -1
View File
@@ -4,7 +4,7 @@ import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/experimental/project/:projectID/copy"
const root = "/experimental/project/:projectID/copy"
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
{
+1 -5
View File
@@ -31,8 +31,6 @@ type App = Effect.Effect<
HttpServerRequest.HttpServerRequest | Scope.Scope
>
export type Transform = (app: App) => App
const errorResponseLogger = HttpMiddleware.make((app) =>
HttpMiddleware.logger(
Effect.tap(app, (response) =>
@@ -44,7 +42,6 @@ const errorResponseLogger = HttpMiddleware.make((app) =>
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
transform?: Transform,
) {
const password = options.password
if (!password) return yield* Effect.fail(new Error("Missing server password"))
@@ -104,8 +101,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
Effect.provideService(Scope.Scope, applicationScope),
)
}
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
yield* status.ready
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
}).pipe(
+8 -26
View File
@@ -1,28 +1,18 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>(
{
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
},
undefined,
(api) =>
api.pipe(
Effect.catchIf(
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
() => Effect.succeed(HttpServerResponse.text("fallback")),
),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
@@ -50,13 +40,5 @@ it.live("allows browser preflight requests without credentials", () =>
expect(health.status).toBe(200)
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
)
expect(missing.status).toBe(200)
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
}),
)
+3 -3
View File
@@ -87,7 +87,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -882,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials }) {
{
name: "server.pair",
title: "Pair device",
slash: { name: "pair", aliases: ["web"] },
slash: { name: "pair" },
run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />)
},
@@ -1240,7 +1240,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<Slot path="app" />
<PluginSlot name="app" input={{}} mode="all" />
</Show>
</box>
</box>
@@ -18,24 +18,17 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex, slashTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
import { Skill } from "@opencode-ai/schema/skill"
import { stringWidth } from "../../util/string-width"
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
slashArgumentAutocomplete,
} from "../../prompt/directory-completion"
export type AutocompleteRef = {
onInput: (value: string) => void
visible: false | "@" | "/" | "directory"
visible: false | "@" | "/"
}
export type AutocompleteOption = {
@@ -47,24 +40,12 @@ export type AutocompleteOption = {
isDirectory?: boolean
onSelect?: () => void
path?: string
absolute?: string
destructive?: { id: string; confirm: string; run: () => void }
kind?: "skill"
}
type AutocompleteResults = {
options: AutocompleteOption[]
failed: boolean
mode: AutocompleteRef["visible"]
query: string
resolved: boolean
}
export function Autocomplete(props: {
value: string
sessionID?: string
argumentAutocomplete?: (command: KeymapCommand) => "directory" | undefined
directoryOptions?: (query: string) => AutocompleteOption[]
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (part: PromptPartRef, extmarkId: number) => void
anchor: () => BoxRenderable
@@ -95,8 +76,6 @@ export function Autocomplete(props: {
})
const [positionTick, setPositionTick] = createSignal(0)
const [dismissedValue, setDismissedValue] = createSignal<string>()
const [confirming, setConfirming] = createSignal<string>()
createEffect(() => {
if (!store.visible) return
@@ -140,9 +119,7 @@ export function Autocomplete(props: {
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
return props
.input()
.getTextRange(store.visible === "directory" ? store.index : store.index + 1, props.input().cursorOffset)
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// filter() reads reactive props.value plus non-reactive cursor/text state.
@@ -289,7 +266,7 @@ export function Autocomplete(props: {
const references = createMemo(() => data.location.reference.list() ?? [])
const referenceMatch = createMemo(() => {
if (store.visible !== "@") return
if (!store.visible || store.visible === "/") return
const base = parseFileLineRange(search()).base
const slash = base.indexOf("/")
const alias = slash === -1 ? base : base.slice(0, slash)
@@ -322,82 +299,36 @@ export function Autocomplete(props: {
insertPart(filename, part)
}
function insertDirectory(directory: string) {
const input = props.input()
const cursorOffset = input.cursorOffset
input.cursorOffset = store.index
const start = input.logicalCursor
input.cursorOffset = cursorOffset
const end = input.logicalCursor
input.deleteRange(start.row, start.col, end.row, end.col)
input.insertText(directory)
}
const [files] = createResource(
() => ({ query: search(), location: location.current, visible: store.visible }),
async (input, info): Promise<AutocompleteResults> => {
if (!input.visible || input.visible === "/")
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
if (referenceMatch())
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
async (input) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
const { lineRange, base } = parseFileLineRange(input.query ?? "")
const directorySearch =
input.visible === "directory"
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
: undefined
const requestLocation = {
directory: directorySearch?.directory ?? input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
}
const result = await (
input.visible === "directory"
? client.api.file.list({ location: requestLocation })
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
).then(
(result) => result,
() => undefined,
)
const result = await client.api.file
.find({
query: base,
limit: 20,
location: {
directory: input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
},
})
.then(
(result) => result,
() => undefined,
)
if (!result)
return info.value?.mode === input.visible
? { ...info.value, failed: true }
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
if (!result) return { options: [], failed: true }
const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in).
const width = props.anchor().width - 4
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
if (exact) {
options.push({
display: Locale.truncateMiddle(exact, width),
value: exact,
isDirectory: true,
path: exact,
absolute: result.location.directory,
onSelect: () => insertDirectory(exact),
})
}
const entries =
input.visible === "directory"
? result.data.filter(
(item) =>
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
)
: result.data
options.push(
...entries.map((item): AutocompleteOption => {
if (input.visible === "directory") {
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
return {
display: Locale.truncateMiddle(directory, width),
value: directory,
isDirectory: true,
path: directory,
absolute: path.resolve(result.location.directory, item.path),
onSelect: () => insertDirectory(directory),
}
}
...result.data.map((item): AutocompleteOption => {
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
@@ -411,27 +342,15 @@ export function Autocomplete(props: {
}),
)
return { options, failed: false, mode: input.visible, query: input.query, resolved: true }
return { options, failed: false }
},
{
initialValue: {
options: [],
failed: false,
mode: false as AutocompleteRef["visible"],
query: "",
resolved: false,
},
initialValue: { options: [], failed: false },
},
)
const visibleFiles = createMemo(() => {
const value = files.loading ? files.latest : files()
if (value?.mode === store.visible) return value
return { options: [], failed: false, query: "", resolved: false }
})
const mcpResources = createMemo(() => {
if (store.visible !== "@") return []
if (!store.visible || store.visible === "/") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
@@ -555,34 +474,14 @@ export function Autocomplete(props: {
}))
})
const supplementalDirectoryOptions = createMemo((): AutocompleteOption[] => {
const results = visibleFiles()
if (store.visible !== "directory" || !results.resolved) return []
const width = props.anchor().width - 4
return (props.directoryOptions?.(results.query) ?? []).map((item) => {
const value = item.value
return {
...item,
display: Locale.truncateMiddle(item.display, width),
onSelect: item.onSelect ?? (value ? () => insertDirectory(value) : undefined),
}
})
})
const options = createMemo(() => {
const fileSearch = visibleFiles()
const fileSearch = files()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()
const searchValue = search()
if (store.visible === "directory") {
const supplemental = supplementalDirectoryOptions()
const paths = new Set(supplemental.map((item) => item.absolute))
return [...supplemental, ...fileSearch.options.filter((item) => !paths.has(item.absolute))]
}
if (store.visible === "@" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
@@ -629,7 +528,6 @@ export function Autocomplete(props: {
createEffect(() => {
filter()
setStore("selected", 0)
setConfirming(undefined)
})
function move(direction: -1 | 1) {
@@ -639,7 +537,6 @@ export function Autocomplete(props: {
}
function moveTo(next: number) {
if (next !== store.selected) setConfirming(undefined)
setStore("selected", next)
if (!scroll) return
const offset = revealSelectionOffset(scroll.scrollTop, {
@@ -654,26 +551,8 @@ export function Autocomplete(props: {
function select() {
const selected = options()[store.selected]
if (!selected) return
if (store.visible !== "directory") {
hide(true)
selected.onSelect?.()
return
}
selected.onSelect?.()
setDismissedValue(props.input().plainText)
hide(true)
}
function triggerDestructive() {
const action = options()[store.selected]?.destructive
if (!action) return false
if (confirming() !== action.id) {
setConfirming(action.id)
return
}
action.run()
setStore("selected", Math.max(0, Math.min(store.selected, options().length - 2)))
setConfirming(undefined)
selected.onSelect?.()
}
function expandDirectory() {
@@ -684,13 +563,7 @@ export function Autocomplete(props: {
const currentCursorOffset = input.cursorOffset
const displayText = (selected.value ?? selected.display).trimEnd()
const selectedPath = displayText.startsWith("@") ? displayText.slice(1) : displayText
if (store.visible === "directory") {
insertDirectory(selectedPath.endsWith(path.sep) ? selectedPath : selectedPath + path.sep)
setStore("selected", 0)
return
}
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
input.cursorOffset = store.index
const startCursor = input.logicalCursor
@@ -698,7 +571,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + selectedPath + "/")
input.insertText("@" + path + "/")
setStore("selected", 0)
}
@@ -756,20 +629,13 @@ export function Autocomplete(props: {
select()
},
},
{
id: "prompt.autocomplete.destructive",
title: "Confirm autocomplete action",
group: "Autocomplete",
bind: "ctrl+d",
run: triggerDestructive,
},
],
}))
function show(mode: Exclude<AutocompleteRef["visible"], false>, index = props.input().cursorOffset) {
function show(mode: "@" | "/") {
setStore({
visible: mode,
index,
index: props.input().cursorOffset,
})
}
@@ -787,7 +653,6 @@ export function Autocomplete(props: {
draft.text = input.plainText
})
}
setConfirming(undefined)
setStore("visible", false)
}
@@ -805,15 +670,6 @@ export function Autocomplete(props: {
return store.visible
},
onInput(value) {
if (dismissedValue() === value) return
setDismissedValue(undefined)
const offset = props.input().cursorOffset
const argument = slashArgumentAutocomplete(value, offset, keymapCommands(), props.argumentAutocomplete)
if (argument?.type === "directory") {
show("directory", argument.index)
return
}
if (store.visible) {
if (
// Typed text before the trigger
@@ -827,6 +683,7 @@ export function Autocomplete(props: {
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
const slash = slashTriggerIndex(value, offset)
@@ -856,18 +713,12 @@ export function Autocomplete(props: {
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const emptyMessage = createMemo(() => {
const fileSearch = visibleFiles()
if (store.visible === "/") return "No matching commands"
if (store.visible === "directory") {
if (files.loading) return "Searching…"
if (fileSearch.failed) return "Could not search directories. Keep typing to try again."
return "No matching directories"
}
if (files.loading) return "Searching…"
if (fileSearch.failed) return "Could not search files. Keep typing to try again."
if (files().failed) return "Could not search files. Keep typing to try again."
return "No matching files, agents, or references"
})
const emptyError = createMemo(() => store.visible === "@" && !files.loading && visibleFiles().failed)
const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed)
return (
<box
@@ -895,60 +746,41 @@ export function Autocomplete(props: {
</box>
}
>
{(option, index) => {
const destructive = () => option().destructive
const confirmingAction = () => {
const action = destructive()
return action !== undefined && action.id === confirming()
}
return (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirmingAction()
? theme.background.action.destructive.focused
: index === store.selected
? theme.background.action.primary.focused
: undefined
}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.background.action.primary.focused : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.default}
flexShrink={0}
>
{option().display}
</text>
<Show when={option().description}>
<text
fg={
confirmingAction()
? theme.text.action.destructive.focused
: index === store.selected
? theme.text.action.primary.focused
: theme.text.default
}
flexShrink={0}
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{confirmingAction() ? destructive()?.confirm : option().display}
{" " + option().description?.trimStart()}
</text>
<Show when={!confirmingAction() && option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
</text>
</Show>
</box>
)
}}
</Show>
</box>
)}
</Index>
</scrollbox>
</box>
+77 -113
View File
@@ -33,7 +33,7 @@ import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteOption, type AutocompleteRef, Autocomplete } from "./autocomplete"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
@@ -58,7 +58,7 @@ import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
@@ -66,8 +66,6 @@ import {
promptAttachmentLabel,
} from "../../prompt/attachment"
import { DialogImagePreview } from "../dialog-image-preview"
import { useDirectoryRecents } from "../../prompt/directory-recents"
import { directoryRecentValue } from "../../prompt/directory-completion"
export type PromptProps = {
sessionID?: string
@@ -161,7 +159,6 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
@@ -230,34 +227,27 @@ export function Prompt(props: PromptProps) {
return
}
const sessionID = props.sessionID
const session = sessionID ? data.session.get(sessionID) : undefined
const sourceProjectID = session?.projectID ?? data.location.info()?.project.id
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
session?.location.directory ?? currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
if (!sessionID) {
const value = input.trim()
const expanded =
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
const directory = path.resolve(
currentLocation.current?.directory ?? data.location.default().directory,
expanded,
)
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return undefined
})
if (!location) return
if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory)
currentLocation.set(location)
return
}
const error = await client.api.session.move({ sessionID, directory: input }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
return
}
if (sourceProjectID) directoryRecents.touch(sourceProjectID, directory)
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
)
},
},
],
@@ -1479,7 +1469,6 @@ export function Prompt(props: PromptProps) {
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1789,106 +1778,81 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<Slot path="prompt.footer" input={footerInput()}>
<Slot path="prompt.footer.status" input={footerInput()}>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
wrapMode="none"
truncate
flexShrink={1}
>
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
</Match>
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</Match>
</Switch>
</box>
</Slot>
<Slot path="prompt.footer.file" input={footerInput()}>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
</box>
<text
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
>
{file()}
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<Spinner color={theme.hue.accent[500]}>
{progress()}
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Show>
</Slot>
</Slot>
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
(new working copy)
</text>
</box>
</Match>
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
</Show>
</Match>
</Switch>
</box>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text
wrapMode="none"
truncate
flexShrink={1}
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
>
{file()}
</text>
)}
</Show>
<PluginSlot
name="prompt.footer.end"
input={{ sessionID: props.sessionID, mode: store.mode }}
mode="replace"
/>
</box>
</box>
<Autocomplete
sessionID={props.sessionID}
argumentAutocomplete={(command) => (command.id === "session.cd" ? "directory" : undefined)}
directoryOptions={(query): AutocompleteOption[] => {
if (query !== "") return []
const projectID =
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ??
data.location.info()?.project.id
if (!projectID) return []
return directoryRecents.list(projectID).map((item) => {
const value = directoryRecentValue(item.directory, paths.home)
return {
display: value,
value,
description: "recent",
isDirectory: true,
path: value,
absolute: item.directory,
destructive: {
id: item.directory,
confirm: "Press ctrl+d to confirm",
run: () => directoryRecents.remove(projectID, item.directory),
},
}
})
}}
ref={(r) => {
setAuto(() => r)
}}
@@ -62,10 +62,6 @@ function View(props: { context: Plugin.Context }) {
export default Plugin.define({
id: "opencode.home-footer",
setup(context) {
// Root takeover: an external plugin replacing home.footer wins (last-
// enabled) and this builtin shows as suppressed, not silently gone.
// Append keeps the path open to additive plugin claims; an external
// replace still takes the boundary over.
context.ui.slot({ append: "home.footer", render: () => <View context={context} /> })
context.ui.slot("home.footer", () => <View context={context} />)
},
})
@@ -85,9 +85,8 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
export default Plugin.define({
id: "opencode.prompt-footer",
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
})
context.ui.slot("prompt.footer.end", (props) => (
<PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />
))
},
})
@@ -44,9 +44,6 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot({
append: "sidebar.content",
render: (props) => <SidebarContext context={context} sessionID={props.sessionID} />,
})
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
},
})
@@ -19,8 +19,6 @@ function View(props: { context: Plugin.Context }) {
export default Plugin.define({
id: "opencode.sidebar-footer",
setup(context) {
// Append keeps the path open to additive plugin claims; an external
// replace still takes the boundary over.
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
context.ui.slot("sidebar.footer", () => <View context={context} />)
},
})
@@ -73,9 +73,6 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
export default Plugin.define({
id: "internal:sidebar-mcp",
setup(context) {
context.ui.slot({
append: "sidebar.content",
render: (props) => <View context={context} sessionID={props.sessionID} />,
})
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
},
})
@@ -1090,6 +1090,6 @@ export default Plugin.define({
name: ROUTE,
render: () => <DiffViewer context={context} />,
})
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -85,6 +85,6 @@ function Commands(props: { context: Plugin.Context }) {
export default Plugin.define({
id,
setup(context) {
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})
@@ -137,6 +137,6 @@ export default Plugin.define({
return <StorybookIndex context={context} />
},
})
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
context.ui.slot("app", () => <Commands context={context} />)
},
})
+8 -30
View File
@@ -1,7 +1,6 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
@@ -24,25 +23,13 @@ import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
// Slot inputs erased to their union: the registry stores one render shape
// regardless of which path a claim targets.
export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
// A registered claim as stored by the plugin provider's registry.
export type RegisteredSlot = {
readonly placement: Placement
readonly render: SlotRender
}
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
// The provider's registration store, narrowed to what a plugin context needs:
// route/slot registration lands there, but ordering and lifecycle stay owned
// by the provider.
export type Registry = {
has(kind: "routes" | "slots" | "markdown", name: string): boolean
set(kind: "routes", name: string, page: Page): void
set(kind: "slots", name: string, claim: RegisteredSlot): void
set(kind: "slots", name: string, slot: Slot): void
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
remove(kind: "routes" | "slots" | "markdown", name: string): void
active(): boolean
@@ -83,7 +70,6 @@ export function createPluginContext(input: {
}): Context {
const host = input.host
let context: Context
let claims = 0
// Every dialog and registered render is wrapped so plugin components can
// reach their own context through usePlugin().
const provide = (render: () => JSX.Element) => (
@@ -198,20 +184,12 @@ export function createPluginContext(input: {
return true
},
},
slot(value: SlotClaim) {
// Keys are counter-suffixed so one plugin may claim several places;
// order within the plugin is registration order.
const key = `slot#${claims++}`
// Exactly one placement kind, enforced at runtime for untyped plugins.
const kinds = placements.filter((item) => value[item] !== undefined)
if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key")
const kind = kinds[0]
input.registry.set("slots", key, {
placement: { kind, target: value[kind] as string },
// The registration map erases the path-specific input type.
render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)),
})
return registration("slots", key)
slot(name, render) {
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
// The registration map erases the slot-specific input type.
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
provide(() => render(slotInput))) as Slot)
return registration("slots", name)
},
},
}
+23 -54
View File
@@ -14,16 +14,15 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createPluginContext, usePluginHost, type Dispose } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
@@ -47,11 +46,9 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slots: {
// A mounted <Slot> instance registers its path; the disposer unregisters.
readonly register: (path: string) => () => void
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
}
readonly slot: <Name extends SlotName>(
name: Name,
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
@@ -65,7 +62,7 @@ type Registration = {
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
slots: Record<string, RegisteredSlot>
slots: Record<string, Slot>
markdown: Record<string, MarkdownCodeBlockRenderer>
cleanups: Dispose[]
}
@@ -122,11 +119,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
owned,
registry: {
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
set: (
kind: "routes" | "slots" | "markdown",
name: string,
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
) => setStore("registrations", id, kind, name, () => value),
set: (kind: "routes" | "slots" | "markdown", name: string, value: Page | Slot | MarkdownCodeBlockRenderer) =>
setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
"registrations",
@@ -393,44 +387,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
// The mounted slot tree: path -> live <Slot> instance count. Reference
// counted because the same path can be mounted several times (one composer
// footer per session tab); a path exists while any instance is mounted.
const [mounted, setMounted] = createStore<Record<string, number>>({})
const registerSlot = (slotPath: string) => {
setMounted(slotPath, (count) => (count ?? 0) + 1)
return () =>
setMounted(
produce((counts) => {
const count = counts[slotPath]
if (count && count > 1) counts[slotPath] = count - 1
else delete counts[slotPath]
}),
)
}
// Claims come back in enable order: registration-store key order across
// plugins (generations preserve key positions in place), then registration
// order within one plugin. The resolver's last-wins rules depend on it.
const claims = createMemo(() =>
Object.entries(store.registrations).flatMap(([id, registration]) =>
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
// Rows downstream diff by reference; a stable claim per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(slot.render)
if (cached) return cached
// Placements are immutable once registered; unwrap the store proxy
// so resolver reads don't subscribe tracked scopes.
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
slotItems.set(slot.render, item)
return item
}),
),
)
// Object.keys tracks the store's keys node only: refcount changes on an
// already-mounted path (a second tab's composer) skip re-resolution.
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
@@ -479,7 +436,19 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
active: plugin.active,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
slot: (name) =>
Object.entries(store.registrations).flatMap(([id, registration]) => {
const render = registration.active ? registration.slots[name] : undefined
if (!render) return []
// <For> diffs rows by reference; a stable wrapper per render
// function keeps untouched plugins' slot rows (and their state)
// alive across other plugins' reloads.
const cached = slotItems.get(render)
if (cached) return [cached]
const item = { id, render }
slotItems.set(render, item)
return [item]
}),
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
+24 -74
View File
@@ -1,21 +1,15 @@
import {
createComponent,
createContext,
createMemo,
ErrorBoundary,
For,
mergeProps,
onCleanup,
onMount,
Show,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import { isShallowEqual } from "remeda"
import type { SlotMap, SlotPath } from "@opencode-ai/plugin/tui/context"
import type { SlotRender } from "./api"
import { contains, emptySlotted, type Claim } from "./structure"
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
import { useRoute } from "../context/route"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
@@ -70,75 +64,31 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
)
}
// The nearest enclosing slot's path. Root slots mount outside any provider.
const SlotParent = createContext<string>()
// `input` is required exactly when the path publishes a non-empty input.
type SlotProps<Path extends SlotPath> = ParentProps<{ readonly path: Path }> &
({} extends SlotMap[Path] ? { readonly input?: SlotMap[Path] } : { readonly input: SlotMap[Path] })
// One named boundary of the host UI's slot tree. The host's own content are
// the children; every active plugin claim targeting this path resolves into
// siblings around it, contributions inside it, or one takeover of it.
// Placement policy lives in resolveSlots; this component only renders its
// own path's buckets.
export function Slot<Path extends SlotPath>(props: SlotProps<Path>) {
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin()
// A slot's path is its identity for the whole mount; instances are
// reference-counted so the same path may be mounted several times (one
// composer footer per session tab).
const path = props.path
// Paths are declared, not inferred: nesting under the wrong parent would
// silently publish a mislocated public path, so containment fails loudly
// at mount. Only host code can trip this — plugins cannot mount slots.
const parent = useContext(SlotParent)
if (parent !== undefined && !contains(parent, path)) {
throw new Error(`Slot "${path}" is mounted inside "${parent}" but its path does not extend it`)
}
onCleanup(plugins.slots.register(path))
const input = () => (props as { readonly input?: SlotMap[Path] }).input ?? ({} as SlotMap[Path])
const slotted = createMemo(
() => plugins.slots.resolved().slotted.get(path) ?? emptySlotted<SlotRender>(),
emptySlotted<SlotRender>(),
// Claim objects are reference-stable across resolutions, so a bucketwise
// comparison makes a claim change elsewhere in the tree a no-op here.
{
equals: (a, b) =>
isShallowEqual(a.before, b.before) &&
isShallowEqual(a.prepend, b.prepend) &&
isShallowEqual(a.append, b.append) &&
isShallowEqual(a.after, b.after) &&
a.replace === b.replace,
},
)
// Component semantics: the render body runs once and untracked, so state
// created inside is stable while the slot input stays reactive through the
// merged getter.
const contribution = (claim: Claim<SlotRender>) => (
<PluginBoundary id={claim.plugin} where={`slot ${path}`}>
{createComponent(claim.render, mergeProps(input))}
</PluginBoundary>
)
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return (
<>
<For each={slotted().before}>{contribution}</For>
{/* before/after are siblings outside the boundary, so outside the provider. */}
<SlotParent.Provider value={path}>
<Show
keyed
when={slotted().replace}
fallback={
<>
<For each={slotted().prepend}>{contribution}</For>
{props.children}
<For each={slotted().append}>{contribution}</For>
</>
<For each={renderers()}>
{(item) => (
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
{
// Component semantics: the render body runs once and untracked, so
// signals and intervals created inside are stable, while props stay
// reactive through the merged getter. A bare item.render(props.input)
// call would run inside the host's tracked scope and re-execute the
// whole body (resetting plugin state) on every tracked read.
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
}
>
{contribution}
</Show>
</SlotParent.Provider>
<For each={slotted().after}>{contribution}</For>
</>
</PluginBoundary>
)}
</For>
)
}
-159
View File
@@ -1,159 +0,0 @@
// Pure resolution of the slot tree: the mounted slot paths plus plugin claims
// in, per-path placement buckets plus diagnostics out. No solid, no I/O —
// every policy rule (replacement takeover, hierarchy-beats-timeline,
// last-enabled-wins, missing-target degradation) is testable as a data
// transform.
export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace"
// Normalized from the public SlotClaim shape by the plugin API: exactly one
// placement kind, the target path erased to a string so the resolver stays
// independent of the slot map.
export type Placement = { readonly kind: PlacementKind; readonly target: string }
// One plugin's registered slot claim, in enable order within the claims array.
export type Claim<Render> = {
readonly key: string
readonly plugin: string
readonly placement: Placement
readonly render: Render
}
// Everything one mounted slot renders besides its own children: siblings
// around the boundary, contributions inside it, and at most one takeover.
export type Slotted<Render> = {
readonly before: ReadonlyArray<Claim<Render>>
readonly prepend: ReadonlyArray<Claim<Render>>
readonly append: ReadonlyArray<Claim<Render>>
readonly after: ReadonlyArray<Claim<Render>>
readonly replace?: Claim<Render>
}
export type Suppressed<Render> = {
readonly claim: Claim<Render>
// The winning claim for a conflict or boundary suppression; absent when a
// replacement's target no longer exists (missing replacements never degrade).
readonly by?: Claim<Render>
}
export type Degraded<Render> = {
readonly claim: Claim<Render>
// The surviving ancestor path the claim was appended to.
readonly to: string
}
const EMPTY: Slotted<never> = { before: [], prepend: [], append: [], after: [] }
export function emptySlotted<Render>(): Slotted<Render> {
return EMPTY
}
// The tree's one containment rule: a path contains every path it prefixes.
// Shared with the <Slot> mount assertion so the spellings cannot drift.
export function contains(ancestor: string, path: string) {
return path.startsWith(ancestor + ".")
}
// `paths` is the set of currently mounted slot paths; `claims` is every
// active claim in plugin enable order. The result maps each targeted path to
// its placement buckets — untargeted paths are absent and render as empty.
export function resolveSlots<Render>(input: {
readonly paths: ReadonlySet<string>
readonly claims: ReadonlyArray<Claim<Render>>
}): {
readonly slotted: ReadonlyMap<string, Slotted<Render>>
readonly suppressed: ReadonlyArray<Suppressed<Render>>
readonly degraded: ReadonlyArray<Degraded<Render>>
} {
const suppressed: Suppressed<Render>[] = []
const degraded: Degraded<Render>[] = []
// Pass 1 — replacement boundaries. Per target the last-enabled claim wins;
// then an accepted boundary swallows every replacement strictly inside it,
// regardless of enable order (hierarchy beats timeline).
const winners = new Map<string, Claim<Render>>()
for (const claim of input.claims) {
if (claim.placement.kind !== "replace") continue
if (!input.paths.has(claim.placement.target)) {
suppressed.push({ claim })
continue
}
const prior = winners.get(claim.placement.target)
if (prior) suppressed.push({ claim: prior, by: claim })
winners.set(claim.placement.target, claim)
}
const boundaries = new Map<string, Claim<Render>>()
const containing = (path: string) => {
for (const [boundary, winner] of boundaries) if (contains(boundary, path)) return winner
return undefined
}
// Shallow boundaries first, so a nested replacement meets its container.
for (const [path, winner] of [...winners].sort((a, b) => depth(a[0]) - depth(b[0]))) {
const outer = containing(path)
if (outer) {
suppressed.push({ claim: winner, by: outer })
continue
}
boundaries.set(path, winner)
}
// Pass 2 — additive claims, in enable order. A claim whose target sits
// inside a replaced boundary is suppressed; a claim whose target is gone
// degrades to appending on the nearest surviving ancestor.
const buckets = new Map<
string,
{ before: Claim<Render>[]; prepend: Claim<Render>[]; append: Claim<Render>[]; after: Claim<Render>[] }
>()
const bucket = (path: string) => {
const existing = buckets.get(path)
if (existing) return existing
const fresh = { before: [], prepend: [], append: [], after: [] }
buckets.set(path, fresh)
return fresh
}
for (const claim of input.claims) {
const kind = claim.placement.kind
if (kind === "replace") continue
const target = claim.placement.target
// Inside placements targeting a replaced boundary are part of its
// contents; sibling placements on the boundary itself stay outside it.
const inside = kind === "prepend" || kind === "append" ? boundaries.get(target) : undefined
const outer = inside ?? containing(target)
if (outer) {
suppressed.push({ claim, by: outer })
continue
}
if (input.paths.has(target)) {
bucket(target)[kind].push(claim)
continue
}
const ancestor = survivingAncestor(target, input.paths)
if (ancestor === undefined) {
suppressed.push({ claim })
continue
}
// The ancestor is never a replaced boundary (containing() caught that).
// Appending is load-bearing: a parent slot registers before its children
// and <Slot> renders append after them, so the transient degradation
// during a nested mount never instantiates anything.
degraded.push({ claim, to: ancestor })
bucket(ancestor).append.push(claim)
}
const slotted = new Map<string, Slotted<Render>>()
for (const [path, lists] of buckets) slotted.set(path, lists)
for (const [path, winner] of boundaries) slotted.set(path, { ...(buckets.get(path) ?? EMPTY), replace: winner })
return { slotted, suppressed, degraded }
}
function depth(path: string) {
return path.split(".").length
}
function survivingAncestor(path: string, paths: ReadonlySet<string>) {
for (let index = path.lastIndexOf("."); index !== -1; index = path.lastIndexOf(".", index - 1)) {
const ancestor = path.slice(0, index)
if (paths.has(ancestor)) return ancestor
}
return undefined
}
@@ -1,75 +0,0 @@
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import path from "path"
import { displaySlice, promptOffsetWidth } from "./display"
import { parseSlashHead } from "./parse"
export function slashArgumentAutocomplete(
value: string,
offset: number,
commands: readonly KeymapCommand[],
autocomplete: ((command: KeymapCommand) => "directory" | undefined) | undefined,
) {
const beforeCursor = displaySlice(value, 0, offset)
const head = parseSlashHead(beforeCursor, /\s/)
if (!head || head.end === beforeCursor.length) return
const command = commands.find(
(command) =>
command.slash?.arguments &&
(command.slash.name === head.name || command.slash.aliases?.includes(head.name) === true),
)
if (!command) return
const type = autocomplete?.(command)
if (!type) return
return {
type,
index: promptOffsetWidth(beforeCursor.slice(0, head.end + 1)),
}
}
export function directoryAutocompleteSearch(query: string, directory: string, home: string) {
if (query === "~") return { directory: home, prefix: "~/", query: "" }
if (query.startsWith("~/")) return directorySearch(query.slice(2), home, "~/")
if (/^(?:\.\.\/)*\.\.$/.test(query))
return { directory: path.resolve(directory, query), prefix: query + "/", query: "" }
if (query.startsWith("/")) return directorySearch(query.slice(1), path.parse(directory).root, "/")
return directorySearch(query, directory, "")
}
function directorySearch(query: string, root: string, prefix: string) {
const separator = query.lastIndexOf("/")
if (separator === -1) return { directory: root, prefix, query }
const parent = query.slice(0, separator + 1)
return {
directory: path.resolve(root, parent),
prefix: prefix + parent,
query: query.slice(separator + 1),
}
}
export function directoryAutocompleteResultValue(
directory: string,
search: ReturnType<typeof directoryAutocompleteSearch>,
) {
return (search.prefix || "./") + directory.replace(/^[\\/]+/, "")
}
export function directoryAutocompleteExactValue(value: string, search: ReturnType<typeof directoryAutocompleteSearch>) {
if (!value || !search.prefix || search.query) return
return value
}
export function directoryAutocompleteMatches(directory: string, query: string) {
const value = directory.replace(/^[\\/]+/, "")
if (!query && value.startsWith(".")) return false
return value.toLowerCase().startsWith(query.toLowerCase())
}
export function directoryRecentValue(directory: string, home: string) {
const relative = path.relative(home, directory)
if (!relative) return "~"
if (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))
return "~/" + relative.split(path.sep).join("/")
return directory
}
@@ -1,36 +0,0 @@
import { useStorage } from "../context/storage"
type RecentDirectory = {
directory: string
usedAt: number
}
type PersistedState = {
projects: Record<string, RecentDirectory[]>
}
export function useDirectoryRecents() {
const [store, updateStore] = useStorage().store<PersistedState>("directory-recents", {
initial: { projects: {} },
key: "directory",
})
return {
list(projectID: string) {
return (store.projects[projectID] ?? []).toSorted((a, b) => b.usedAt - a.usedAt)
},
touch(projectID: string, directory: string) {
void updateStore((draft) => {
draft.projects[projectID] = [
{ directory, usedAt: Date.now() },
...(draft.projects[projectID] ?? []).filter((item) => item.directory !== directory),
].slice(0, 10)
}).catch((error) => console.error("Failed to persist directory recents", error))
},
remove(projectID: string, directory: string) {
void updateStore((draft) => {
draft.projects[projectID] = (draft.projects[projectID] ?? []).filter((item) => item.directory !== directory)
}).catch((error) => console.error("Failed to remove directory recent", error))
},
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { useEditorContext } from "../context/editor"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { Slot } from "../plugin/render"
import { PluginSlot } from "../plugin/render"
import { useTerminalDimensions } from "@opentui/solid"
let once = false
@@ -91,7 +91,7 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
</box>
<box width="100%" flexShrink={0}>
<Slot path="home.footer" />
<PluginSlot name="home.footer" input={{}} mode="replace" />
</box>
<Show when={forms()[0]?.id} keyed>
{(_) => {
+2 -2
View File
@@ -86,7 +86,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { Slot } from "../../plugin/render"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import {
cacheReuseDrop,
@@ -1088,7 +1088,7 @@ export function Session() {
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { Slot } from "../../plugin/render"
import { PluginSlot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { getScrollAcceleration } from "../../util/scroll"
@@ -52,12 +52,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<Slot path="sidebar.footer" />
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
</box>
</box>
</Show>
@@ -8,8 +8,8 @@ import type {
KeymapCommand,
KeymapLayer,
Page,
SlotClaim,
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import { emptyThemeSource } from "../../fixture/fixture"
@@ -143,7 +143,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
const commands = new Map<string, KeymapCommand>()
let current = initialRoute ?? startRoute
let renderDiff: Page["render"] | undefined
let renderCommands: SlotClaim<"app">["render"] | undefined
let renderCommands: Slot | undefined
let vcsDiffInput: unknown
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
@@ -200,8 +200,8 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
},
current: () => current,
},
slot(claim: SlotClaim<"app">) {
renderCommands = claim.render
slot(_name: string, render: Slot) {
renderCommands = render
return () => {}
},
},
+2 -5
View File
@@ -140,11 +140,8 @@ import { appendFile } from "node:fs/promises"
export default {
id: "test.crash",
setup: async (context: any) => {
context.ui.slot({
replace: "home.footer",
render: () => {
throw new Error("boom")
},
context.ui.slot("home.footer", () => {
throw new Error("boom")
})
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
},
-208
View File
@@ -1,208 +0,0 @@
import { expect, test } from "bun:test"
import type { SlotClaim } from "@opencode-ai/plugin/tui/context"
import { resolveSlots, type Claim, type PlacementKind } from "../src/plugin/structure"
// Type-level canaries, checked by `bun typecheck`: exactly one placement key,
// absolute paths only, and the render input follows the targeted path.
export const canaries = () => {
const claims: SlotClaim[] = []
claims.push({ append: "prompt.footer", render: (input) => (input.mode === "shell" ? null : null) })
claims.push({ after: "prompt.footer.status", render: () => null })
// @ts-expect-error two placement keys cannot coexist
claims.push({ append: "prompt.footer", before: "prompt.footer.status", render: () => null })
// @ts-expect-error replace does not combine with an anchor
claims.push({ replace: "prompt.footer.status", after: "prompt.footer.file", render: () => null })
// @ts-expect-error targets must be absolute published paths
claims.push({ after: "status", render: () => null })
// @ts-expect-error the render input is the targeted slot's input
claims.push({ append: "prompt.footer", render: (input: { mode: number }) => null })
return claims
}
// The resolver is generic over render values; strings make layout assertions
// read as layouts. Placements are written in the public claim shape and
// normalized here, like the plugin API does.
function claim(plugin: string, placement: Partial<Record<PlacementKind, string>>, render: string): Claim<string> {
const kind = (["prepend", "append", "before", "after", "replace"] as const).find((item) => placement[item])!
return { key: `${plugin}/${render}`, plugin, placement: { kind, target: placement[kind]! }, render }
}
// A host slot tree for tests: a node's children are its child slots, a leaf's
// content is its own name. Mirrors how nested <Slot> components mount.
type Node = { readonly path: string; readonly children?: ReadonlyArray<Node> }
function paths(nodes: ReadonlyArray<Node>, into = new Set<string>()): Set<string> {
for (const node of nodes) {
into.add(node.path)
paths(node.children ?? [], into)
}
return into
}
// Fold the tree with a resolution into the flat render order, mirroring the
// <Slot> component: before + (replace | prepend + own content + append) + after.
function layout(nodes: ReadonlyArray<Node>, resolved: ReturnType<typeof resolveSlots<string>>): ReadonlyArray<string> {
return nodes.flatMap((node) => {
const slotted = resolved.slotted.get(node.path)
const own = node.children ? layout(node.children, resolved) : [leafName(node.path)]
const inside = slotted?.replace
? [slotted.replace.render]
: [
...(slotted?.prepend ?? []).map((item) => item.render),
...own,
...(slotted?.append ?? []).map((item) => item.render),
]
return [
...(slotted?.before ?? []).map((item) => item.render),
...inside,
...(slotted?.after ?? []).map((item) => item.render),
]
})
}
function leafName(path: string) {
return path.slice(path.lastIndexOf(".") + 1)
}
function resolve(tree: ReadonlyArray<Node>, claims: ReadonlyArray<Claim<string>>) {
return resolveSlots({ paths: paths(tree), claims })
}
const footer: Node[] = [
{
path: "prompt.footer",
children: [{ path: "prompt.footer.status" }, { path: "prompt.footer.file" }],
},
]
const tree: Node[] = [
{
path: "prompt.footer",
children: [
{ path: "prompt.footer.left", children: [{ path: "prompt.footer.left.mode" }] },
{
path: "prompt.footer.right",
children: [
{ path: "prompt.footer.right.directory" },
{ path: "prompt.footer.right.model" },
{ path: "prompt.footer.right.tokens" },
],
},
],
},
]
test("no claims renders the host tree in order", () => {
const result = resolve(footer, [])
expect(layout(footer, result)).toEqual(["status", "file"])
expect(result.suppressed).toEqual([])
expect(result.degraded).toEqual([])
})
test("prepend and append land inside a boundary's edges, several in enable order", () => {
const result = resolve(footer, [
claim("a", { append: "prompt.footer" }, "a1"),
claim("b", { prepend: "prompt.footer" }, "b1"),
claim("a", { append: "prompt.footer" }, "a2"),
])
expect(layout(footer, result)).toEqual(["b1", "status", "file", "a1", "a2"])
})
test("before and after anchor to a slot, wherever the host keeps it", () => {
const result = resolve(footer, [
claim("a", { after: "prompt.footer.status" }, "chip"),
claim("b", { before: "prompt.footer.status" }, "vim"),
])
expect(layout(footer, result)).toEqual(["vim", "status", "chip", "file"])
})
test("a missing anchor degrades to the nearest surviving ancestor's end", () => {
const result = resolve(footer, [claim("a", { after: "prompt.footer.tokens" }, "chip")])
expect(layout(footer, result)).toEqual(["status", "file", "chip"])
expect(result.degraded).toEqual([
{ claim: claim("a", { after: "prompt.footer.tokens" }, "chip"), to: "prompt.footer" },
])
})
test("an additive claim with no surviving ancestor is suppressed", () => {
const result = resolve(footer, [claim("a", { append: "session.composer.top" }, "chip")])
expect(layout(footer, result)).toEqual(["status", "file"])
expect(result.suppressed).toEqual([{ claim: claim("a", { append: "session.composer.top" }, "chip") }])
})
test("a missing replacement is suppressed, never degraded into a widget", () => {
const result = resolve(footer, [claim("a", { replace: "prompt.footer.tokens" }, "cost")])
expect(layout(footer, result)).toEqual(["status", "file"])
expect(result.suppressed).toEqual([{ claim: claim("a", { replace: "prompt.footer.tokens" }, "cost") }])
expect(result.degraded).toEqual([])
})
test("replacing a slot swaps content but keeps the boundary and its outside anchors", () => {
const fancy = claim("a", { replace: "prompt.footer.status" }, "fancy-status")
const result = resolve(footer, [fancy, claim("b", { after: "prompt.footer.status" }, "chip")])
expect(layout(footer, result)).toEqual(["fancy-status", "chip", "file"])
expect(result.suppressed).toEqual([])
})
test("inside contributions to a replaced boundary are suppressed", () => {
const takeover = claim("a", { replace: "prompt.footer" }, "powerline")
const badge = claim("b", { append: "prompt.footer" }, "badge")
const result = resolve(footer, [badge, takeover])
expect(layout(footer, result)).toEqual(["powerline"])
expect(result.suppressed).toEqual([{ claim: badge, by: takeover }])
})
test("same target: the last-enabled replacement wins and the loser is recorded", () => {
const first = claim("a", { replace: "prompt.footer.status" }, "first")
const second = claim("b", { replace: "prompt.footer.status" }, "second")
const result = resolve(footer, [first, second])
expect(layout(footer, result)).toEqual(["second", "file"])
expect(result.suppressed).toEqual([{ claim: first, by: second }])
})
test("container takeover suppresses everything anchored in the subtree", () => {
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
const chip = claim("pr", { after: "prompt.footer.right.model" }, "chip")
const inner = claim("x", { replace: "prompt.footer.right.tokens" }, "cost")
const result = resolve(tree, [takeover, chip, inner])
expect(layout(tree, result)).toEqual(["mode", "my-right"])
expect(result.suppressed).toEqual([
{ claim: inner, by: takeover },
{ claim: chip, by: takeover },
])
})
test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => {
// The descendant replace was enabled after the container takeover; the
// container still wins because its path contains the descendant's.
const inner = claim("x", { replace: "prompt.footer.right.model" }, "swap-model")
const outer = claim("theme", { replace: "prompt.footer.right" }, "my-right")
const result = resolve(tree, [outer, inner])
expect(layout(tree, result)).toEqual(["mode", "my-right"])
expect(result.suppressed).toEqual([{ claim: inner, by: outer }])
})
test("root takeover: nothing original survives, all inside claims suppressed", () => {
const theme = claim("powerline", { replace: "prompt.footer" }, "powerline")
const chip = claim("pr", { append: "prompt.footer" }, "chip")
const result = resolve(tree, [chip, theme])
expect(layout(tree, result)).toEqual(["powerline"])
expect(result.suppressed).toEqual([{ claim: chip, by: theme }])
})
test("a degraded claim landing inside a replaced boundary is suppressed, not shown", () => {
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
const stray = claim("pr", { after: "prompt.footer.right.gone" }, "chip")
const result = resolve(tree, [takeover, stray])
expect(layout(tree, result)).toEqual(["mode", "my-right"])
expect(result.suppressed).toEqual([{ claim: stray, by: takeover }])
expect(result.degraded).toEqual([])
})
test("anchors on a container wrap its whole span", () => {
const result = resolve(tree, [
claim("a", { before: "prompt.footer.right" }, "divider"),
claim("b", { after: "prompt.footer.right" }, "clock"),
])
expect(layout(tree, result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"])
})
@@ -1,162 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
import {
directoryAutocompleteExactValue,
directoryAutocompleteMatches,
directoryAutocompleteResultValue,
directoryAutocompleteSearch,
directoryRecentValue,
slashArgumentAutocomplete,
} from "../../src/prompt/directory-completion"
const commands = [
{
id: "session.cd",
slash: { name: "cd", aliases: ["chdir"], arguments: true },
run: () => undefined,
},
] satisfies KeymapCommand[]
const argumentAutocomplete = (command: KeymapCommand) =>
command.id === "session.cd" ? ("directory" as const) : undefined
describe("slashArgumentAutocomplete", () => {
test("starts after the command separator", () => {
expect(slashArgumentAutocomplete("/cd ", 4, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 4,
})
expect(slashArgumentAutocomplete("/cd src", 7, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 4,
})
})
test("supports aliases", () => {
expect(slashArgumentAutocomplete("/chdir src", 10, commands, argumentAutocomplete)).toEqual({
type: "directory",
index: 7,
})
})
test("does not complete the command token", () => {
expect(slashArgumentAutocomplete("/cd", 3, commands, argumentAutocomplete)).toBeUndefined()
expect(slashArgumentAutocomplete("/other ", 7, commands, argumentAutocomplete)).toBeUndefined()
})
})
describe("directoryAutocompleteSearch", () => {
test("searches from home after a home prefix", () => {
expect(directoryAutocompleteSearch("~", "/project", "/home/user")).toEqual({
directory: "/home/user",
prefix: "~/",
query: "",
})
expect(directoryAutocompleteSearch("~/pro", "/project", "/home/user")).toEqual({
directory: "/home/user",
prefix: "~/",
query: "pro",
})
expect(directoryAutocompleteSearch("~/projects/open", "/project", "/home/user")).toEqual({
directory: "/home/user/projects",
prefix: "~/projects/",
query: "open",
})
})
test("searches from parent prefixes", () => {
expect(directoryAutocompleteSearch("..", "/project/src", "/home/user")).toEqual({
directory: "/project",
prefix: "../",
query: "",
})
expect(directoryAutocompleteSearch("../../pac", "/project/src/lib", "/home/user")).toEqual({
directory: "/project",
prefix: "../../",
query: "pac",
})
expect(directoryAutocompleteSearch("../../..", "/project/src/lib", "/home/user")).toEqual({
directory: "/",
prefix: "../../../",
query: "",
})
})
test("keeps ordinary searches rooted at the current directory", () => {
expect(directoryAutocompleteSearch("src", "/project", "/home/user")).toEqual({
directory: "/project",
prefix: "",
query: "src",
})
expect(directoryAutocompleteSearch("packages/core", "/project", "/home/user")).toEqual({
directory: "/project/packages",
prefix: "packages/",
query: "core",
})
expect(directoryAutocompleteSearch("/root/pro", "/project", "/home/user")).toEqual({
directory: "/root",
prefix: "/root/",
query: "pro",
})
})
})
describe("directoryAutocompleteResultValue", () => {
test("marks current-directory results as relative", () => {
const search = directoryAutocompleteSearch("", "/project", "/home/user")
expect(directoryAutocompleteResultValue("src/", search)).toBe("./src/")
expect(directoryAutocompleteResultValue("/src/", search)).toBe("./src/")
expect(directoryAutocompleteResultValue("/", search)).toBe("./")
})
test("preserves explicit roots", () => {
expect(
directoryAutocompleteResultValue("projects/", directoryAutocompleteSearch("~/", "/project", "/home/user")),
).toBe("~/projects/")
expect(
directoryAutocompleteResultValue("src/", directoryAutocompleteSearch("../", "/project/pkg", "/home/user")),
).toBe("../src/")
})
})
describe("directoryAutocompleteExactValue", () => {
test("includes complete explicit roots", () => {
expect(
directoryAutocompleteExactValue("../..", directoryAutocompleteSearch("../..", "/project/pkg", "/home/user")),
).toBe("../..")
expect(directoryAutocompleteExactValue("~", directoryAutocompleteSearch("~", "/project", "/home/user"))).toBe("~")
})
test("omits incomplete and implicit roots", () => {
expect(
directoryAutocompleteExactValue("../../src", directoryAutocompleteSearch("../../src", "/project", "/home/user")),
).toBeUndefined()
expect(
directoryAutocompleteExactValue("", directoryAutocompleteSearch("", "/project", "/home/user")),
).toBeUndefined()
})
})
describe("directoryAutocompleteMatches", () => {
test("hides dot directories for an empty component", () => {
expect(directoryAutocompleteMatches("src/", "")).toBe(true)
expect(directoryAutocompleteMatches(".git/", "")).toBe(false)
})
test("shows dot directories when explicitly filtered", () => {
expect(directoryAutocompleteMatches(".git/", ".")).toBe(true)
expect(directoryAutocompleteMatches(".github/", ".gi")).toBe(true)
expect(directoryAutocompleteMatches(".zed/", ".gi")).toBe(false)
})
})
describe("directoryRecentValue", () => {
test("abbreviates home paths", () => {
expect(directoryRecentValue("/home/user", "/home/user")).toBe("~")
expect(directoryRecentValue("/home/user/projects/opencode", "/home/user")).toBe("~/projects/opencode")
})
test("keeps paths outside home absolute", () => {
expect(directoryRecentValue("/project/recent", "/home/user")).toBe("/project/recent")
})
})
-22
View File
@@ -22,15 +22,6 @@ export default defineConfig({
branch: "dev",
dir: "packages/www",
},
theme: {
background: "#131010",
fonts: {
body: "ibm-plex-mono",
display: "ibm-plex-mono",
mono: "ibm-plex-mono",
},
mode: "dark",
},
navigation: {
tabs: [
{ label: "Docs", path: "/" },
@@ -43,19 +34,6 @@ export default defineConfig({
route: "/api",
spec: "./openapi.json",
},
seo: {
og: {
fonts: [{ name: "IBM Plex Mono", weight: [400, 600] }],
logo: "public/assets/logo-dark.svg",
palette: {
accent: "#b7b1b1",
background: "#131010",
border: "#343030",
foreground: "#f1ecec",
muted: "#b7b1b1",
},
},
},
deployment: {
adapter: "cloudflare",
base: "/v2/",
@@ -161,8 +161,8 @@ Skills are keyed by ID. If several sources define the same ID, the later source
wins. Sources are registered in this order, from lower to higher precedence:
1. Built-in skills
2. `.claude/skills` sources, global first and then from the farthest ancestor toward the current directory
3. `.agents/skills` sources, global first and then from the farthest ancestor toward the current directory
2. `.claude/skills` sources, global first and then from the current directory upward
3. `.agents/skills` sources, global first and then from the current directory upward
4. `~/.config/opencode/skills`
5. Project `.opencode/skills`, from the project root toward the current directory
6. Explicit `skills` config entries, in config priority and array order
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/copy": {
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/api/experimental/project/{projectID}/copy/refresh": {
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/api/experimental/project/{projectID}/copy": {
"/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/api/experimental/project/{projectID}/copy/refresh": {
"/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",