Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 7c5182ecaf cli: inline text imports as content in the node bundle
Vite's built-in asset plugin claims known asset types (.txt) before
normal-priority plugins load, rewriting the import to an asset URL
string — the models.dev snapshot shipped as "/assets/snapshot-*.txt"
and cold-cache catalog access died decoding it. Pre-existing instances
of the same class: the node bundle's prompt and tool-description .txt
imports. enforce: "pre" intercepts first; a build assertion now fails
the build if text imports surface as asset URLs, since the bundle still
builds and runs --help convincingly without their content.

Also: drop the redundant text.d.ts (bun-types already declares *.txt),
give the snapshot refresh script a provider-count floor and
Windows-safe path printing.
2026-08-11 16:56:15 -04:00
Kit Langton cf570e9a22 core: embed models.dev snapshot instead of compile-time define
The models.dev catalog snapshot previously reached core only through the
OPENCODE_MODELS_DEV compile-time define, injected solely by the CLI
builds. Any embedded/SDK-built server got no snapshot: with a cold cache
and fetch disabled or failed, the catalog was empty, ModelsDevPlugin
registered zero env methods, and the provider env-key credential chain
never engaged even with e.g. ANTHROPIC_API_KEY set.

Commit the snapshot at packages/core/src/models-dev/snapshot.txt (raw
api.json, refreshed via script/update-models-snapshot.ts) and load it in
loadSnapshot via a static text import, so every build profile gets the
same boot-time floor. Runtime fetch/refresh behavior is unchanged: file
and KV cache still take precedence, and the periodic refresh still
fetches on top. Delete the define plumbing from the CLI build scripts
and vite config, and the build-time fetch in script/generate.ts.
2026-08-11 16:15:38 -04:00
83 changed files with 677 additions and 2381 deletions
+1
View File
@@ -1,3 +1,4 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/models-dev/snapshot.txt linguist-generated
packages/core/src/**/*.txt text eol=lf
@@ -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()
}
+22 -12
View File
@@ -2,17 +2,15 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
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,23 +54,35 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
// Vite silently rewrites text imports of known asset types (.txt) to asset
// URL strings when the raw-text plugin doesn't intercept them first — the
// bundle still builds and `--help` still runs, so only content assertions
// catch it. Guards the models.dev snapshot and the prompt/tool description
// text that ships inside the bundle.
async function assertTextImportsInlined(bundlePath: string) {
const bundle = await readFile(bundlePath, "utf8")
const markers = [
{ marker: '"zhipuai"', source: "models-dev snapshot" },
{ marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
{ marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
]
for (const { marker, source, forbidden } of markers) {
const present = bundle.includes(marker)
if (forbidden ? present : !present)
throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`)
}
}
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, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
await assertTextImportsInlined("dist-node/opencode.mjs")
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+1 -18
View File
@@ -7,8 +7,6 @@ import { Script } from "@opencode-ai/script"
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 +53,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 +79,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,
@@ -114,7 +98,6 @@ for (const item of targets) {
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
-9
View File
@@ -1,9 +0,0 @@
import { readFile } from "node:fs/promises"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")
-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)),
)
})
})
+6 -20
View File
@@ -10,26 +10,18 @@ const dir = import.meta.dirname
function rawTextPlugin(): Plugin {
return {
name: "opencode:raw-text",
// "pre" is load-bearing for .txt: Vite's built-in asset plugin claims
// known asset types (.txt among them) ahead of normal-priority plugins,
// replacing the import with an asset URL string instead of the content.
// .md only ever worked without it because .md is not a known asset type.
enforce: "pre",
async load(id) {
if (!id.endsWith(".md")) return
if (!id.endsWith(".md") && !id.endsWith(".txt")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
}
}
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",
@@ -222,17 +214,14 @@ if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
export type NodeBuildInput = {
readonly version: string
readonly channel: string
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(),
@@ -248,7 +237,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
define: {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_MODELS_DEV: input.models,
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
@@ -271,8 +259,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
export default mainConfig({
version: process.env.OPENCODE_VERSION ?? "local",
channel: process.env.OPENCODE_CHANNEL ?? "local",
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",
+2 -1
View File
@@ -10,6 +10,7 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
@@ -28,7 +29,7 @@
"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",
@@ -0,0 +1,24 @@
#!/usr/bin/env bun
/**
* Refreshes the bundled models.dev catalog snapshot at src/models-dev/snapshot.txt.
* The snapshot is the boot-time floor for the catalog when no cache entry exists
* and fetching is disabled or unavailable; live fetch still refreshes on top.
*/
const source = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const response = await fetch(`${source}/api.json`)
if (!response.ok) {
console.error(`Failed to fetch ${source}/api.json: ${response.status} ${response.statusText}`)
process.exit(1)
}
const text = await response.text()
const parsed: unknown = JSON.parse(text)
// A floor, not equality: guards against committing an error page or a
// truncated body that still parses as a small object.
const MINIMUM_PROVIDERS = 100
if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length < MINIMUM_PROVIDERS) {
console.error(`Fetched catalog has fewer than ${MINIMUM_PROVIDERS} providers; refusing to write snapshot`)
process.exit(1)
}
const target = new URL("../src/models-dev/snapshot.txt", import.meta.url)
await Bun.write(target, text)
console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`)
-21
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":
+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) }))
+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),
+12 -6
View File
@@ -11,6 +11,7 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -519,8 +520,6 @@ function modelInfo(
export { Event } from "@opencode-ai/schema/models-dev"
declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<readonly Snapshot[]>
readonly refresh: (force?: boolean) => Effect.Effect<void>
@@ -530,12 +529,17 @@ export const Options = Schema.Struct({
url: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
fetch: Schema.optional(Schema.Boolean),
snapshot: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(
Effect.map((catalog) => catalog as Record<string, SourceProvider>),
)
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
@@ -605,13 +609,15 @@ export const layer = (options?: Options) =>
)
: Effect.succeed(undefined)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
// for the catalog; the periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
const catalog = yield* decodeCatalog(text)
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
File diff suppressed because one or more lines are too long
+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())
+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],
})
-29
View File
@@ -273,35 +273,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()),
@@ -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)
-51
View File
@@ -763,57 +763,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"), {
+21 -6
View File
@@ -228,7 +228,20 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
)
expect(result).toEqual([])
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -237,7 +250,9 @@ describe("ModelsDev Service", () => {
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual([])
expect(result.length).toBeGreaterThan(0)
const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
@@ -248,7 +263,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
@@ -263,7 +278,7 @@ describe("ModelsDev Service", () => {
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
@@ -281,7 +296,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true, snapshot: false })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -296,7 +311,7 @@ describe("ModelsDev Service", () => {
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true, snapshot: false })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
+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
-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")
})
})
+62 -84
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"
@@ -39,7 +36,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 } 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 +124,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 +165,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"
@@ -218,49 +203,42 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
})
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 +577,7 @@ describe("ShellTool", () => {
)
it.live(
"reports shell ID progress once",
"does not repeat shell ID progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -609,7 +587,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",