Compare commits

..

16 Commits

Author SHA1 Message Date
James Long 4f08d14c98 refactor(tui): clarify directory completion module 2026-08-12 00:07:25 +00:00
James Long 7833f92514 refactor(tui): keep cd completion metadata local 2026-08-12 00:04:53 +00:00
James Long d0f75776ca fix(tui): align recents with autocomplete results 2026-08-11 23:59:28 +00:00
James Long 527aa6fa4d fix(tui): synchronize recent directory options 2026-08-11 23:52:17 +00:00
James Long edc667c650 fix(tui): preserve autocomplete results while loading 2026-08-11 23:49:59 +00:00
James Long 021dc17ad7 fix(tui): hide dot directories in cd completion 2026-08-11 23:44:24 +00:00
James Long 1ff43c8d8a refactor(tui): list cd directories 2026-08-11 23:40:47 +00:00
James Long 56a70de106 feat(tui): autocomplete cd directories 2026-08-11 23:14:33 +00:00
opencode-agent[bot] 6072fc5c1b chore: generate 2026-08-11 22:44:47 +00:00
Brendan Allan 20df2fdba6 feat(cli): embed web ui (#41525)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-11 15:43:07 -07:00
opencode-agent[bot] f1399dcb65 fix(desktop): orchestrate update restarts (#41865)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-12 08:38:18 +10:00
Dax Raad 3507bd10b6 fix(www): match OG images to docs theme 2026-08-11 18:28:51 -04:00
Dax Raad 492ae1fdfa feat(www): match legacy docs theme 2026-08-11 18:20:58 -04:00
Kit Langton b00d8d65fe feat(tui): hierarchical slot tree for plugin UI placement (#41189) 2026-08-11 22:00:04 +00:00
Kit Langton f1366d80c8 fix(core): default #sqlite resolution to the node driver (#41834) 2026-08-11 17:50:09 -04:00
opencode-agent[bot] 549a8f826b fix(core): log Plan reminder failures (#41856)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-11 16:39:26 -05:00
57 changed files with 1916 additions and 492 deletions
@@ -3,6 +3,7 @@ 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 }
@@ -31,7 +32,14 @@ export function useUpdaterAction() {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
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 !== "check") return
const state = await platform.updater?.check()
+41
View File
@@ -0,0 +1,41 @@
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()
}
+10 -1
View File
@@ -12,6 +12,7 @@ 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, "..")
@@ -55,13 +56,21 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appArchive,
}
await copyNodeAssets(assets)
await build(mainConfig(input))
+16 -1
View File
@@ -8,6 +8,7 @@ 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"
@@ -54,6 +55,20 @@ 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"}` : ""}`
@@ -80,7 +95,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin, parcelWatcherPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+51
View File
@@ -0,0 +1,51 @@
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 server",
description: "Start the v2 API and web server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+10 -7
View File
@@ -13,6 +13,7 @@ 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"
@@ -43,16 +44,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)
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
const incumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== 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.
@@ -68,6 +69,7 @@ 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: {
@@ -122,6 +124,7 @@ 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)
@@ -143,7 +146,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 (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
if (foreground && !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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: string
export default archive
}
+70
View File
@@ -0,0 +1,70 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
await writeFile(index, "<html><body>embedded</body></html>")
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"font.woff2": new Uint8Array([0, 1, 2, 255]),
}
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transform = yield* WebUi.handler({ assets })
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
yield* http.serve(
transform(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
}),
)
}),
),
)
const origin = HttpServer.formatAddress(http.address)
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
new Uint8Array([0, 1, 2, 255]),
)
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
})
})
+16
View File
@@ -17,6 +17,19 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(archive: string): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${JSON.stringify(archive)}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -212,12 +225,14 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appArchive: string
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appArchive),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -259,4 +274,5 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
})
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/api/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: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/api/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: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
path: `/api/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"
}
},
"/experimental/project/{projectID}/copy": {
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -9536,7 +9536,7 @@
}
}
},
"/experimental/project/{projectID}/copy/refresh": {
"/api/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+1 -1
View File
@@ -28,7 +28,7 @@
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
"default": "./src/database/sqlite.node.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
+5 -1
View File
@@ -52,7 +52,11 @@ export const Plugin = define({
text,
resume: false,
})
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }),
),
)
}),
Effect.forkScoped({ startImmediately: true }),
)
+49
View File
@@ -0,0 +1,49 @@
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")
})
})
+2 -1
View File
@@ -25,6 +25,7 @@ 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,
@@ -283,7 +284,6 @@ 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,6 +292,7 @@ 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,8 +20,6 @@ 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"
@@ -46,7 +44,6 @@ 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>
@@ -56,8 +53,6 @@ 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()))
@@ -80,20 +75,6 @@ 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,25 +1,31 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const backend: UpdaterBackend = {
async checkForUpdates() {
const platform: UpdaterPlatform = {
async checkForUpdate() {
calls.push("check")
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
return "2.0.0"
},
async downloadUpdate() {
async stageUpdate() {
calls.push("download")
},
quitAndInstall() {
installAndRestart() {
calls.push("install")
return new Promise<never>(() => {})
},
}
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
backend,
platform,
lifecycle: {
async prepareToRestart() {
calls.push("prepare")
},
},
persistence: {
get: () => ready,
set: (value) => {
@@ -29,9 +35,6 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
ready = undefined
},
},
stop: async () => {
calls.push("stop")
},
})
return { controller, calls, getReady: () => ready }
}
@@ -76,36 +79,81 @@ describe("updater controller", () => {
expect(app.calls).toEqual(["check", "download"])
})
test("returns to ready when quitAndInstall returns without exiting", async () => {
test("starts installing synchronously and coalesces restart requests", async () => {
const app = setup()
await app.controller.start()
await app.controller.install()
const first = app.controller.install()
const second = app.controller.install()
expect(app.calls).toEqual(["check", "download", "stop", "install"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
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" })
})
test("returns to ready when installation cannot start", async () => {
test("does not check for updates while installation is in progress", 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",
backend: {
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
downloadUpdate: async () => {},
quitAndInstall() {},
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart: () => Promise.reject(error),
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
stop: async () => {
throw new Error("stop failed")
},
})
await failed.start()
await expect(failed.install()).rejects.toThrow("stop failed")
await expect(failed.install()).rejects.toThrow("install 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" })
})
})
+45 -26
View File
@@ -4,10 +4,14 @@ export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterBackend = {
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
downloadUpdate(): Promise<unknown>
quitAndInstall(): void
export type UpdaterPlatform = {
checkForUpdate(): Promise<string | undefined>
stageUpdate(): Promise<unknown>
installAndRestart(): Promise<never>
}
export type UpdaterLifecycle = {
prepareToRestart(): Promise<void>
}
type UpdaterPersistence = {
@@ -19,13 +23,14 @@ type UpdaterPersistence = {
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
backend: UpdaterBackend
platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle
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) => {
@@ -37,20 +42,21 @@ export function createUpdaterController(input: {
const check = () => {
if (!input.enabled) return Promise.resolve(state)
if (state.status === "ready") 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 (pending) return pending
pending = (async () => {
transition({ status: "checking" })
const result = await input.backend.checkForUpdates()
const version = result?.updateInfo?.version
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await input.backend.downloadUpdate()
await platform.stageUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})()
@@ -63,6 +69,33 @@ 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) {
@@ -76,21 +109,7 @@ export function createUpdaterController(input: {
return check()
},
check,
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
})
},
install,
}
}
+23
View File
@@ -0,0 +1,23 @@
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())
}
@@ -0,0 +1,89 @@
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)))
}
})
}
+4 -35
View File
@@ -1,51 +1,21 @@
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(stop: () => Promise<void>) {
export function setupAutoUpdater(prepareToRestart: () => 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(),
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
}
},
},
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
lifecycle: { prepareToRestart },
persistence: {
get() {
const value = store.get(key)
@@ -55,7 +25,6 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
stop,
log: (message, data) => logger.log(message, data),
})
}
+79 -13
View File
@@ -150,24 +150,89 @@ 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.end": {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "session.composer.top": {
readonly sessionID: string
}
readonly "sidebar.content": {
readonly sessionID: string
}
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 "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotPath = keyof SlotMap
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
/**
* 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 interface App {
readonly version: string
@@ -394,7 +459,8 @@ export interface UI {
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
/** Claims a place in the slot tree; see SlotClaim. */
readonly slot: (claim: SlotClaim) => () => void
}
export interface Context {
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/experimental/project/{projectID}/copy": {
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/experimental/project/{projectID}/copy/refresh": {
"/api/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 = "/experimental/project/:projectID/copy"
const root = "/api/experimental/project/:projectID/copy"
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
{
+5 -1
View File
@@ -31,6 +31,8 @@ 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) =>
@@ -42,6 +44,7 @@ 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"))
@@ -101,7 +104,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
Effect.provideService(Scope.Scope, applicationScope),
)
}
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
}).pipe(
+26 -8
View File
@@ -1,18 +1,28 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { HttpServer, HttpServerError, HttpServerResponse } 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:" },
})
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 response = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
@@ -40,5 +50,13 @@ 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")
}),
)
+4 -31
View File
@@ -30,7 +30,7 @@ import {
batch,
Show,
} from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -62,7 +62,6 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogExperiments } from "./component/dialog-experiments"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -88,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, PluginSlot } from "./plugin/render"
import { PluginRoute, Slot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -658,22 +657,8 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
// With per-tab drafts, a new session is an explicit "this belongs
// elsewhere" gesture: move the in-progress draft instead of leaving
// a copy behind on the tab it came from.
const carried = (() => {
if (config.data.experimental?.tab_drafts !== true) return undefined
const current = promptRef.current
if (!current?.current.text) return undefined
// Copy before reset: reset() merges an empty prompt into the same
// underlying store object that unwrap exposes.
const prompt = { ...unwrap(current.current) }
current.reset()
return prompt
})()
route.navigate({
type: "home",
prompt: carried,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -885,18 +870,6 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
{
// Deliberately absent from the command palette; reachable only by the
// secret /baldbeard incantation.
name: "opencode.experiments",
title: "Experiments",
palette: undefined,
slash: { name: "baldbeard" },
run: () => {
dialog.replace(() => <DialogExperiments />)
},
category: "System",
},
{
name: "opencode.status",
title: "View status",
@@ -909,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials }) {
{
name: "server.pair",
title: "Pair device",
slash: { name: "pair" },
slash: { name: "pair", aliases: ["web"] },
run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />)
},
@@ -1267,7 +1240,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<PluginSlot name="app" input={{}} mode="all" />
<Slot path="app" />
</Show>
</box>
</box>
@@ -1,63 +0,0 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
type Experiment = {
id: "tab_drafts"
title: string
description: string
}
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "tab_drafts",
title: "Per-tab prompt drafts",
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
},
]
export function DialogExperiments() {
const config = useConfig()
const toast = useToast()
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment, index) => ({
title: experiment.title,
description: experiment.description,
category: "Experiments",
footer: enabled(experiment) ? "on" : "off",
value: index,
})),
)
async function toggle(index: number) {
if (saving()) return
const experiment = experiments[index]
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
await config
.update((draft) => {
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
draft.experimental[experiment.id] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<DialogSelect
title="Experiments"
options={options()}
onSelect={(option) => void toggle(option.value)}
footerHints={[{ title: "enter", label: "toggle" }]}
/>
)
}
@@ -18,17 +18,24 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { Keymap } from "../../context/keymap"
import { Keymap, type KeymapCommand } 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 | "@" | "/"
visible: false | "@" | "/" | "directory"
}
export type AutocompleteOption = {
@@ -40,12 +47,24 @@ 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
@@ -76,6 +95,8 @@ export function Autocomplete(props: {
})
const [positionTick, setPositionTick] = createSignal(0)
const [dismissedValue, setDismissedValue] = createSignal<string>()
const [confirming, setConfirming] = createSignal<string>()
createEffect(() => {
if (!store.visible) return
@@ -119,7 +140,9 @@ 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.index + 1, props.input().cursorOffset)
return props
.input()
.getTextRange(store.visible === "directory" ? store.index : store.index + 1, props.input().cursorOffset)
})
// filter() reads reactive props.value plus non-reactive cursor/text state.
@@ -266,7 +289,7 @@ export function Autocomplete(props: {
const references = createMemo(() => data.location.reference.list() ?? [])
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
if (store.visible !== "@") return
const base = parseFileLineRange(search()).base
const slash = base.indexOf("/")
const alias = slash === -1 ? base : base.slice(0, slash)
@@ -299,36 +322,82 @@ 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) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
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 }
const { lineRange, base } = parseFileLineRange(input.query ?? "")
const directorySearch =
input.visible === "directory"
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
: 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,
)
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,
)
if (!result) return { options: [], failed: true }
if (!result)
return info.value?.mode === input.visible
? { ...info.value, failed: true }
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
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(
...result.data.map((item): AutocompleteOption => {
...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),
}
}
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
@@ -342,15 +411,27 @@ export function Autocomplete(props: {
}),
)
return { options, failed: false }
return { options, failed: false, mode: input.visible, query: input.query, resolved: true }
},
{
initialValue: { options: [], failed: false },
initialValue: {
options: [],
failed: false,
mode: false as AutocompleteRef["visible"],
query: "",
resolved: 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 || store.visible === "/") return []
if (store.visible !== "@") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
@@ -474,14 +555,34 @@ 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 = files()
const fileSearch = visibleFiles()
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}`)
}
@@ -528,6 +629,7 @@ export function Autocomplete(props: {
createEffect(() => {
filter()
setStore("selected", 0)
setConfirming(undefined)
})
function move(direction: -1 | 1) {
@@ -537,6 +639,7 @@ 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, {
@@ -551,8 +654,26 @@ export function Autocomplete(props: {
function select() {
const selected = options()[store.selected]
if (!selected) return
hide(true)
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)
}
function expandDirectory() {
@@ -563,7 +684,13 @@ export function Autocomplete(props: {
const currentCursorOffset = input.cursorOffset
const displayText = (selected.value ?? selected.display).trimEnd()
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
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
}
input.cursorOffset = store.index
const startCursor = input.logicalCursor
@@ -571,7 +698,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path + "/")
input.insertText("@" + selectedPath + "/")
setStore("selected", 0)
}
@@ -629,13 +756,20 @@ export function Autocomplete(props: {
select()
},
},
{
id: "prompt.autocomplete.destructive",
title: "Confirm autocomplete action",
group: "Autocomplete",
bind: "ctrl+d",
run: triggerDestructive,
},
],
}))
function show(mode: "@" | "/") {
function show(mode: Exclude<AutocompleteRef["visible"], false>, index = props.input().cursorOffset) {
setStore({
visible: mode,
index: props.input().cursorOffset,
index,
})
}
@@ -653,6 +787,7 @@ export function Autocomplete(props: {
draft.text = input.plainText
})
}
setConfirming(undefined)
setStore("visible", false)
}
@@ -670,6 +805,15 @@ 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
@@ -683,7 +827,6 @@ 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)
@@ -713,12 +856,18 @@ 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 (files().failed) return "Could not search files. Keep typing to try again."
if (fileSearch.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 && files().failed)
const emptyError = createMemo(() => store.visible === "@" && !files.loading && visibleFiles().failed)
return (
<box
@@ -746,41 +895,60 @@ export function Autocomplete(props: {
</box>
}
>
{(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, 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().display}
</text>
<Show when={option().description}>
<text
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
fg={
confirmingAction()
? theme.text.action.destructive.focused
: index === store.selected
? theme.text.action.primary.focused
: theme.text.default
}
flexShrink={0}
>
{" " + option().description?.trimStart()}
{confirmingAction() ? destructive()?.confirm : option().display}
</text>
</Show>
</box>
)}
<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>
)
}}
</Index>
</scrollbox>
</box>
+116 -95
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 AutocompleteRef, Autocomplete } from "./autocomplete"
import { type AutocompleteOption, 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 { PluginSlot } from "../../plugin/render"
import { Slot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import {
deduplicatePromptImages,
@@ -66,6 +66,8 @@ 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
@@ -130,11 +132,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
// One in-progress draft survives remounts. By default a single slot follows
// focus across tabs; the tab_drafts experiment keys drafts to the tab (session
// or home) they were written in.
let stashed: { prompt: PromptInfo; cursor: number } | undefined
const stashedByTab = new Map<string, { prompt: PromptInfo; cursor: number }>()
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
@@ -163,6 +161,7 @@ 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
@@ -231,27 +230,34 @@ 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
}
await client.api.session
.move({ sessionID, directory: input })
.catch((error) =>
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }),
)
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)
},
},
],
@@ -651,17 +657,9 @@ export function Prompt(props: PromptProps) {
},
}
// Captured once: the session route is keyed by sessionID, so this Prompt
// instance belongs to exactly one tab. Reading props.sessionID lazily would
// observe the *next* route during onCleanup and stash under the wrong tab.
const stashSessionID = props.sessionID
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
onMount(() => {
const key = stashKey()
const saved = key === undefined ? stashed : stashedByTab.get(key)
if (key === undefined) stashed = undefined
else stashedByTab.delete(key)
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -674,10 +672,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
const entry = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
const key = stashKey()
if (key === undefined) stashed = entry
else stashedByTab.set(key, entry)
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
@@ -1484,6 +1479,7 @@ 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
@@ -1793,81 +1789,106 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<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} />
<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>
)}
</Show>
</box>
</Match>
</Switch>
</box>
</Slot>
<Slot path="prompt.footer.file" input={footerInput()}>
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<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}
>
esc{" "}
<span
style={{
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
{file()}
</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>
)}
</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"
/>
</Show>
</Slot>
</Slot>
</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)
}}
-7
View File
@@ -189,13 +189,6 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Debugging settings" }),
experimental: Schema.optional(
Schema.Struct({
tab_drafts: Schema.optional(Schema.Boolean).annotate({
description: "Keep unsent prompt drafts on the tab where they were written",
}),
}),
).annotate({ description: "Experimental features that may change or be removed at any time" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
cursor: Schema.optional(Cursor),
@@ -62,6 +62,10 @@ function View(props: { context: Plugin.Context }) {
export default Plugin.define({
id: "opencode.home-footer",
setup(context) {
context.ui.slot("home.footer", () => <View context={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} /> })
},
})
@@ -85,8 +85,9 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
export default Plugin.define({
id: "opencode.prompt-footer",
setup(context) {
context.ui.slot("prompt.footer.end", (props) => (
<PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />
))
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
})
},
})
@@ -44,6 +44,9 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
export default Plugin.define({
id: "internal:sidebar-context",
setup(context) {
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
context.ui.slot({
append: "sidebar.content",
render: (props) => <SidebarContext context={context} sessionID={props.sessionID} />,
})
},
})
@@ -19,6 +19,8 @@ function View(props: { context: Plugin.Context }) {
export default Plugin.define({
id: "opencode.sidebar-footer",
setup(context) {
context.ui.slot("sidebar.footer", () => <View context={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} /> })
},
})
@@ -73,6 +73,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
export default Plugin.define({
id: "internal:sidebar-mcp",
setup(context) {
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
context.ui.slot({
append: "sidebar.content",
render: (props) => <View context={context} sessionID={props.sessionID} />,
})
},
})
@@ -1090,6 +1090,6 @@ export default Plugin.define({
name: ROUTE,
render: () => <DiffViewer context={context} />,
})
context.ui.slot("app", () => <Commands context={context} />)
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
},
})
@@ -85,6 +85,6 @@ function Commands(props: { context: Plugin.Context }) {
export default Plugin.define({
id,
setup(context) {
context.ui.slot("app", () => <Commands context={context} />)
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
},
})
@@ -137,6 +137,6 @@ export default Plugin.define({
return <StorybookIndex context={context} />
},
})
context.ui.slot("app", () => <Commands context={context} />)
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
},
})
+30 -8
View File
@@ -1,6 +1,7 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
@@ -23,13 +24,25 @@ 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, slot: Slot): void
set(kind: "slots", name: string, claim: RegisteredSlot): void
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
remove(kind: "routes" | "slots" | "markdown", name: string): void
active(): boolean
@@ -70,6 +83,7 @@ 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) => (
@@ -184,12 +198,20 @@ export function createPluginContext(input: {
return true
},
},
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)
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)
},
},
}
+54 -23
View File
@@ -14,15 +14,16 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
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 { 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 } from "./api"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
@@ -46,9 +47,11 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(
name: Name,
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly slots: {
// A mounted <Slot> instance registers its path; the disposer unregisters.
readonly register: (path: string) => () => void
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
}
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
@@ -62,7 +65,7 @@ type Registration = {
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
slots: Record<string, Slot>
slots: Record<string, RegisteredSlot>
markdown: Record<string, MarkdownCodeBlockRenderer>
cleanups: Dispose[]
}
@@ -119,8 +122,11 @@ 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 | Slot | MarkdownCodeBlockRenderer) =>
setStore("registrations", id, kind, name, () => value),
set: (
kind: "routes" | "slots" | "markdown",
name: string,
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
) => setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
"registrations",
@@ -387,7 +393,44 @@ 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<Slot, { readonly id: string; readonly render: Slot }>()
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() }))
createEffect(
on(
() => JSON.stringify(config.data.plugins ?? []),
@@ -436,19 +479,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
active: plugin.active,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
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]
}),
slots: { register: registerSlot, resolved },
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
+74 -24
View File
@@ -1,15 +1,21 @@
import {
createComponent,
createContext,
createMemo,
ErrorBoundary,
For,
mergeProps,
onCleanup,
onMount,
Show,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
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 { useRoute } from "../context/route"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
@@ -64,31 +70,75 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
)
}
export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
// 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>) {
const plugins = usePlugin()
const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
// 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>
)
return (
<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])
<>
<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>
</>
}
</PluginBoundary>
)}
</For>
>
{contribution}
</Show>
</SlotParent.Provider>
<For each={slotted().after}>{contribution}</For>
</>
)
}
+159
View File
@@ -0,0 +1,159 @@
// 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
}
@@ -0,0 +1,75 @@
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
}
@@ -0,0 +1,36 @@
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 { PluginSlot } from "../plugin/render"
import { Slot } 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}>
<PluginSlot name="home.footer" input={{}} mode="replace" />
<Slot path="home.footer" />
</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 { PluginSlot } from "../../plugin/render"
import { Slot } 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>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
<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 { PluginSlot } from "../../plugin/render"
import { Slot } 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>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
</box>
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
<Slot path="sidebar.footer" />
</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: Slot | undefined
let renderCommands: SlotClaim<"app">["render"] | 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(_name: string, render: Slot) {
renderCommands = render
slot(claim: SlotClaim<"app">) {
renderCommands = claim.render
return () => {}
},
},
+5 -2
View File
@@ -140,8 +140,11 @@ import { appendFile } from "node:fs/promises"
export default {
id: "test.crash",
setup: async (context: any) => {
context.ui.slot("home.footer", () => {
throw new Error("boom")
context.ui.slot({
replace: "home.footer",
render: () => {
throw new Error("boom")
},
})
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
},
+208
View File
@@ -0,0 +1,208 @@
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"])
})
@@ -0,0 +1,162 @@
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,6 +22,15 @@ 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: "/" },
@@ -34,6 +43,19 @@ 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/",
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/experimental/project/{projectID}/copy": {
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/experimental/project/{projectID}/copy/refresh": {
"/api/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+2 -2
View File
@@ -10930,7 +10930,7 @@
"summary": "List references"
}
},
"/experimental/project/{projectID}/copy": {
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
}
}
},
"/experimental/project/{projectID}/copy/refresh": {
"/api/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",