Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 9e26c3a00d effect: introduce defineService and drop per-call Effect.provide
Wave A migrated TuiConfig/Installation callers to
`AppRuntime.runPromise(svc.use(fn).pipe(Effect.provide(svc.layer)))`,
which couples every call site to the service's layer construction.

`defineService(Service, layer)` composes `makeRuntime` on top of an
existing service so the service exposes its own runPromise/runFork/etc.
Each per-service runtime shares the global memoMap, so dependencies are
still built once.

Call sites collapse from:
  await AppRuntime.runPromise(
    TuiConfig.Service.use((svc) => svc.get()).pipe(Effect.provide(TuiConfig.layer)),
  )
to:
  await TuiConfig.runPromise((svc) => svc.get())

Same shape applied to Installation. No per-method async wrappers.
2026-05-12 19:32:23 -04:00
Kit Langton 9f290d4381 effect(cli): kill TuiConfig/Installation/SessionCompaction facades
Remove makeRuntime + async facade exports from TuiConfig, Installation, and
SessionCompaction. Migrate production callers to AppRuntime.runPromise with
yielded services, and route tests through dedicated mockable hooks where
they previously spied on the facade.
2026-05-12 16:29:18 -04:00
20 changed files with 111 additions and 96 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ Most of the original facade-removal backlog is already done. The practical remai
## Checklist ## Checklist
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service` - [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service` - [x] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - facades removed
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed - [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed - [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed - [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
@@ -40,7 +40,7 @@ export type SessionInfo = {
variant: string | undefined variant: string | undefined
} }
type Config = Awaited<ReturnType<typeof TuiConfig.get>> type Config = TuiConfig.Resolved
type BootService = { type BootService = {
readonly resolveModelInfo: ( readonly resolveModelInfo: (
sdk: RunInput["sdk"], sdk: RunInput["sdk"],
@@ -60,8 +60,14 @@ const configTask: { current?: Promise<Config> } = {}
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {} class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
// Exposed on `TuiConfigLoader` so tests can mock without depending on the
// TuiConfig namespace; production calls the TuiConfig per-service runtime.
export const TuiConfigLoader = {
get: () => TuiConfig.runPromise((svc) => svc.get()),
}
function loadConfig() { function loadConfig() {
return reusePendingTask(configTask, () => TuiConfig.get()) return reusePendingTask(configTask, () => TuiConfigLoader.get())
} }
function emptyModelInfo(): ModelInfo { function emptyModelInfo(): ModelInfo {
+1 -1
View File
@@ -66,7 +66,7 @@ export const AttachCommand = cmd({
} }
})() })()
const headers = ServerAuth.headers({ password: args.password, username: args.username }) const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get() const config = await TuiConfig.runPromise((svc) => svc.get())
const { tui } = await import("./app") const { tui } = await import("./app")
try { try {
@@ -14,9 +14,9 @@ import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CurrentWorkingDirectory } from "./cwd" import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { defineService } from "@/effect/run-service"
import { TuiKeybind } from "./keybind" import { TuiKeybind } from "./keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
@@ -246,12 +246,4 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) export const { runPromise, runFork, runCallback } = defineService(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
@@ -42,6 +42,11 @@ import { createCommandShim } from "./command-shim"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
// Exposed as a separate function so tests can mock it without touching the
// TuiConfig service. Production callers wait on plugin install fibers via the
// TuiConfig per-service runtime; tests replace this with a no-op.
export const waitForDependencies = () => TuiConfig.runPromise((svc) => svc.waitForDependencies())
type PluginLoad = { type PluginLoad = {
options: ConfigPlugin.Options | undefined options: ConfigPlugin.Options | undefined
spec: string spec: string
@@ -837,7 +842,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => { const ready = await resolveExternalPlugins([cfg], () => waitForDependencies()).catch((error) => {
fail("failed to add tui plugin", { path: next, error }) fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[] return [] as PluginLoad[]
}) })
@@ -1049,7 +1054,7 @@ async function load(input: { api: Api; config: TuiConfig.Resolved }) {
}) })
} }
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) const ready = await resolveExternalPlugins(records, () => waitForDependencies())
await addExternalPluginEntries(next, ready) await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config) applyInitialPluginEnabledState(next, config)
+1 -1
View File
@@ -187,7 +187,7 @@ export const TuiThreadCommand = cmd({
} }
const prompt = await input(args.prompt) const prompt = await input(args.prompt)
const config = await TuiConfig.get() const config = await TuiConfig.runPromise((svc) => svc.get())
const network = resolveNetworkOptionsNoConfig(args) const network = resolveNetworkOptionsNoConfig(args)
const external = const external =
+1 -1
View File
@@ -57,7 +57,7 @@ export const UninstallCommand = {
UI.empty() UI.empty()
prompts.intro("Uninstall OpenCode") prompts.intro("Uninstall OpenCode")
const method = await Installation.method() const method = await Installation.runPromise((svc) => svc.method())
prompts.log.info(`Installation method: ${method}`) prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method) const targets = await collectRemovalTargets(args, method)
+11 -3
View File
@@ -3,6 +3,7 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
export const UpgradeCommand = { export const UpgradeCommand = {
command: "upgrade [target]", command: "upgrade [target]",
@@ -25,7 +26,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
prompts.intro("Upgrade") prompts.intro("Upgrade")
const detectedMethod = await Installation.method() const detectedMethod = await Installation.runPromise((svc) => svc.method())
const method = (args.method as Installation.Method) ?? detectedMethod const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") { if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`) prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -43,7 +44,9 @@ export const UpgradeCommand = {
} }
} }
prompts.log.info("Using method: " + method) prompts.log.info("Using method: " + method)
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest() const target = args.target
? args.target.replace(/^v/, "")
: await Installation.runPromise((svc) => svc.latest())
if (InstallationVersion === target) { if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -54,7 +57,12 @@ export const UpgradeCommand = {
prompts.log.info(`From ${InstallationVersion}${target}`) prompts.log.info(`From ${InstallationVersion}${target}`)
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Upgrading...") spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err) const err = await Installation.runPromise((installation) =>
installation.upgrade(method, target).pipe(
Effect.map(() => undefined as Installation.UpgradeFailedError | Error | undefined),
Effect.catch((error) => Effect.succeed(error as Installation.UpgradeFailedError | Error | undefined)),
),
).catch((err: Error) => err)
if (err) { if (err) {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
+29 -19
View File
@@ -4,30 +4,40 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
export async function upgrade() { export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) await AppRuntime.runPromise(
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return Effect.gen(function* () {
const method = await Installation.method() const cfg = yield* Config.Service
const latest = await Installation.latest(method).catch(() => {}) const installation = yield* Installation.Service
if (!latest) return const bus = yield* Bus.Service
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { const config = yield* cfg.getGlobal()
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
return const method = yield* installation.method()
} const latest = yield* installation.latest(method).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!latest) return
if (InstallationVersion === latest) return if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
yield* bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
const kind = Installation.getReleaseType(InstallationVersion, latest) if (InstallationVersion === latest) return
if (config.autoupdate === "notify" || kind !== "patch") { const kind = Installation.getReleaseType(InstallationVersion, latest)
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
if (method === "unknown") return if (config.autoupdate === "notify" || kind !== "patch") {
await Installation.upgrade(method, latest) yield* bus.publish(Installation.Event.UpdateAvailable, { version: latest })
.then(() => Bus.publish(Installation.Event.Updated, { version: latest })) return
.catch(() => {}) }
if (method === "unknown") return
yield* installation.upgrade(method, latest).pipe(
Effect.flatMap(() => bus.publish(Installation.Event.Updated, { version: latest })),
Effect.catch(() => Effect.void),
)
}),
)
} }
@@ -55,3 +55,16 @@ export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Laye
getRuntime().runCallback(attach(service.use(fn))), getRuntime().runCallback(attach(service.use(fn))),
} }
} }
/**
* Composition of `makeRuntime` for services that own their own runtime. Returns
* the service class, the supplied layer, and the per-service runtime methods
* (`runPromise`, `runFork`, etc.). Each runtime uses the shared `memoMap`, so
* dependencies are built once across all `defineService` runtimes.
*
* Use for services that don't belong in `AppLayer` (TUI-specific config, etc.)
* or for services where call sites want to bypass `AppRuntime` provisioning.
*/
export function defineService<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
return { Service: service, layer, ...makeRuntime(service, layer) }
}
+2 -6
View File
@@ -7,10 +7,10 @@ import path from "path"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver" import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config" import { NpmConfig } from "@opencode-ai/core/npm-config"
import { defineService } from "@/effect/run-service"
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
@@ -325,10 +325,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) export const { runPromise, runFork, runCallback } = defineService(Service, defaultLayer)
export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
export * as Installation from "." export * as Installation from "."
@@ -16,7 +16,6 @@ import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime" import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow" import { isOverflow as overflow, usable } from "./overflow"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use" import { serviceUse } from "@/effect/service-use"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { SessionEvent } from "@/v2/session-event" import { SessionEvent } from "@/v2/session-event"
@@ -649,14 +648,4 @@ export const defaultLayer = Layer.suspend(() =>
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
return runPromise((svc) => svc.isOverflow(input))
}
export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export * as SessionCompaction from "./compaction" export * as SessionCompaction from "./compaction"
@@ -3,10 +3,15 @@ import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap" import type { Binding } from "@opentui/keymap"
import { createBindingLookup } from "@opentui/keymap/extras" import { createBindingLookup } from "@opentui/keymap/extras"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared" import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" import {
TuiConfigLoader,
resolveDiffStyle,
resolveFooterKeybinds,
resolveModelInfo,
} from "@/cli/cmd/run/runtime.boot"
type RunBinding = Binding<Renderable, KeyEvent> type RunBinding = Binding<Renderable, KeyEvent>
@@ -108,7 +113,7 @@ describe("run runtime boot", () => {
}) })
test("reads footer keybinds from resolved keybind config", async () => { test("reads footer keybinds from resolved keybind config", async () => {
spyOn(TuiConfig, "get").mockResolvedValue( spyOn(TuiConfigLoader, "get").mockResolvedValue(
config({ config({
leader: "ctrl+g", leader: "ctrl+g",
bindings: { bindings: {
@@ -139,7 +144,7 @@ describe("run runtime boot", () => {
}) })
test("falls back to default keybinds when config load fails", async () => { test("falls back to default keybinds when config load fails", async () => {
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) spyOn(TuiConfigLoader, "get").mockRejectedValue(new Error("boom"))
const result = await resolveFooterKeybinds() const result = await resolveFooterKeybinds()
@@ -156,11 +161,11 @@ describe("run runtime boot", () => {
}) })
test("reads diff style and falls back to auto", async () => { test("reads diff style and falls back to auto", async () => {
spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" })) spyOn(TuiConfigLoader, "get").mockResolvedValue(config({ diff_style: "stacked" }))
await expect(resolveDiffStyle()).resolves.toBe("stacked") await expect(resolveDiffStyle()).resolves.toBe("stacked")
mock.restore() mock.restore()
spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom")) spyOn(TuiConfigLoader, "get").mockRejectedValue(new Error("boom"))
await expect(resolveDiffStyle()).resolves.toBe("auto") await expect(resolveDiffStyle()).resolves.toBe("auto")
}) })
@@ -5,8 +5,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("adds tui plugin at runtime from spec", async () => { test("adds tui plugin at runtime from spec", async () => {
@@ -35,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => {
const config = createTuiResolvedConfig({ const config = createTuiResolvedConfig({
plugin: [], plugin: [],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -77,7 +75,7 @@ test("retries runtime add for file plugins after dependency wait", async () => {
const config = createTuiResolvedConfig({ const config = createTuiResolvedConfig({
plugin: [], plugin: [],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockImplementation(async () => {
await Bun.write( await Bun.write(
path.join(tmp.extra.mod, "index.ts"), path.join(tmp.extra.mod, "index.ts"),
`export default { `export default {
@@ -5,8 +5,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("installs plugin without loading it", async () => { test("installs plugin without loading it", async () => {
@@ -54,7 +52,7 @@ test("installs plugin without loading it", async () => {
const config = createTuiResolvedConfig({ const config = createTuiResolvedConfig({
plugin: [], plugin: [],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({ const api = createTuiPluginApi({
state: { state: {
@@ -5,7 +5,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -55,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -116,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -178,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -240,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -298,7 +297,7 @@ test("does not use npm package main for tui entry", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const warn = spyOn(console, "warn").mockImplementation(() => {}) const warn = spyOn(console, "warn").mockImplementation(() => {})
@@ -363,7 +362,7 @@ test("does not use directory package main for tui entry", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -410,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -467,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -5,8 +5,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("skips external tui plugins in pure mode", async () => { test("skips external tui plugins in pure mode", async () => {
@@ -48,7 +46,7 @@ test("skips external tui plugins in pure mode", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -7,7 +7,6 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime" import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -339,7 +338,7 @@ export default {
}, },
}) })
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
try { try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -539,7 +538,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -784,7 +783,7 @@ test("auto-disposes plugin keymap layers", async () => {
} }
}, },
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"] } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -830,7 +829,7 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
}) })
const harness = createTestKeymap({ defaultKeys: true }) const harness = createTestKeymap({ defaultKeys: true })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -893,7 +892,7 @@ test("auto-disposes plugin keymap transformers", async () => {
prependCommandTransformer: track, prependCommandTransformer: track,
appendCommandTransformer: track, appendCommandTransformer: track,
} as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"] } as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -950,7 +949,7 @@ test("manual onDispose for plugin keymap layers stays idempotent", async () => {
} }
}, },
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"] } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try { try {
@@ -1021,7 +1020,7 @@ test("updates installed theme when plugin metadata changes", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const mkApi = () => const mkApi = () =>
createTuiPluginApi({ createTuiPluginApi({
@@ -5,8 +5,6 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("toggles plugin runtime state by exported id", async () => { test("toggles plugin runtime state by exported id", async () => {
@@ -53,7 +51,7 @@ test("toggles plugin runtime state by exported id", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi() const api = createTuiPluginApi()
@@ -130,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
}, },
], ],
}) })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi() const api = createTuiPluginApi()
api.kv.set("plugin_enabled", { api.kv.set("plugin_enabled", {
@@ -160,7 +158,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
test("loads disabled-by-default internal plugin inactive and activates on demand", async () => { test("loads disabled-by-default internal plugin inactive and activates on demand", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi() const api = createTuiPluginApi()
@@ -2,6 +2,7 @@ import { spyOn } from "bun:test"
import path from "path" import path from "path"
import { createBindingLookup } from "@opentui/keymap/extras" import { createBindingLookup } from "@opentui/keymap/extras"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { TuiPluginRuntime } from "../../src/cli/cmd/tui/plugin/runtime"
import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
type PluginSpec = string | [string, Record<string, unknown>] type PluginSpec = string | [string, Record<string, unknown>]
@@ -34,7 +35,7 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi
scope: "local" as const, scope: "local" as const,
source: path.join(dir, "tui.json"), source: path.join(dir, "tui.json"),
})) }))
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiPluginRuntime, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir) const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
const config = createTuiResolvedConfig({ const config = createTuiResolvedConfig({