Bläddra i källkod

refactor: remove makeRuntime facade from TuiConfig

Delete get() and waitForDependencies() facade functions from config/tui.ts.
Add TuiConfig.defaultLayer to AppLayer so the service is available via
AppRuntime.

Migrate all callers (attach.ts, thread.ts, plugin/runtime.ts) to
AppRuntime.runPromise(TuiConfig.Service.use(...)). Migrate tui.test.ts
(~28 calls) to the same pattern.

Rework test fixture: replace spyOn(TuiConfig, 'get') with mockTuiService
helper that mocks TuiConfig.Service.use at the Effect level. Update all
9 test files that spied on the old facades.
Kit Langton 3 dagar sedan
förälder
incheckning
a8d6379e0e

+ 2 - 1
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -4,6 +4,7 @@ import { tui } from "./app"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/config/tui"
 import { Instance } from "@/project/instance"
+import { AppRuntime } from "@/effect/app-runtime"
 import { existsSync } from "fs"
 
 export const AttachCommand = cmd({
@@ -68,7 +69,7 @@ export const AttachCommand = cmd({
       })()
       const config = await Instance.provide({
         directory: directory && existsSync(directory) ? directory : process.cwd(),
-        fn: () => TuiConfig.get(),
+        fn: () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())),
       })
       await tui({
         url: args.url,

+ 9 - 3
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -15,6 +15,7 @@ import { fileURLToPath } from "url"
 
 import { Config } from "@/config/config"
 import { TuiConfig } from "@/config/tui"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Log } from "@/util/log"
 import { errorData, errorMessage } from "@/util/error"
 import { isRecord } from "@/util/record"
@@ -794,7 +795,10 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
 
   const ready = await Instance.provide({
     directory: state.directory,
-    fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
+    fn: () =>
+      resolveExternalPlugins([cfg], () =>
+        AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.waitForDependencies())),
+      ),
   }).catch((error) => {
     fail("failed to add tui plugin", { path: next, error })
     return [] as PluginLoad[]
@@ -991,7 +995,7 @@ export namespace TuiPluginRuntime {
     await Instance.provide({
       directory: cwd,
       fn: async () => {
-        const config = await TuiConfig.get()
+        const config = await AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get()))
         const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
         if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
           log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
@@ -1011,7 +1015,9 @@ export namespace TuiPluginRuntime {
           })
         }
 
-        const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
+        const ready = await resolveExternalPlugins(records, () =>
+          AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.waitForDependencies())),
+        )
         await addExternalPluginEntries(next, ready)
 
         applyInitialPluginEnabledState(next, config)

+ 2 - 1
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/config/tui"
 import { Instance } from "@/project/instance"
+import { AppRuntime } from "@/effect/app-runtime"
 import { writeHeapSnapshot } from "v8"
 
 declare global {
@@ -179,7 +180,7 @@ export const TuiThreadCommand = cmd({
       const prompt = await input(args.prompt)
       const config = await Instance.provide({
         directory: cwd,
-        fn: () => TuiConfig.get(),
+        fn: () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())),
       })
 
       const network = await resolveNetworkOptions(args)

+ 0 - 11
packages/opencode/src/config/tui.ts

@@ -12,7 +12,6 @@ import { isRecord } from "@/util/record"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 
 export namespace TuiConfig {
@@ -170,16 +169,6 @@ export namespace TuiConfig {
 
   export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
 
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function get() {
-    return runPromise((svc) => svc.get())
-  }
-
-  export async function waitForDependencies() {
-    await runPromise((svc) => svc.waitForDependencies())
-  }
-
   async function loadFile(filepath: string): Promise<Info> {
     const text = await ConfigPaths.readFile(filepath)
     if (!text) return {}

+ 2 - 0
packages/opencode/src/effect/app-runtime.ts

@@ -47,6 +47,7 @@ import { Pty } from "@/pty"
 import { Installation } from "@/installation"
 import { ShareNext } from "@/share/share-next"
 import { SessionShare } from "@/share/session"
+import { TuiConfig } from "@/config/tui"
 
 export const AppLayer = Layer.mergeAll(
   Observability.layer,
@@ -95,6 +96,7 @@ export const AppLayer = Layer.mergeAll(
   Installation.defaultLayer,
   ShareNext.defaultLayer,
   SessionShare.defaultLayer,
+  TuiConfig.defaultLayer,
 )
 
 const rt = ManagedRuntime.make(AppLayer, { memoMap })

+ 20 - 18
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -1,10 +1,11 @@
 import { expect, spyOn, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
+import { Effect } from "effect"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -31,11 +32,10 @@ test("adds tui plugin at runtime from spec", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [],
     plugin_origins: undefined,
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -54,8 +54,7 @@ test("adds tui plugin at runtime from spec", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -72,22 +71,27 @@ test("retries runtime add for file plugins after dependency wait", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
-    plugin: [],
-    plugin_origins: undefined,
-  })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
-    await Bun.write(
-      path.join(tmp.extra.mod, "index.ts"),
-      `export default {
+  const restore = mockTuiService(
+    {
+      plugin: [],
+      plugin_origins: undefined,
+    },
+    {
+      wait: () =>
+        Effect.promise(async () => {
+          await Bun.write(
+            path.join(tmp.extra.mod, "index.ts"),
+            `export default {
   id: "demo.add.retry",
   tui: async () => {
     await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
   },
 }
 `,
-    )
-  })
+          )
+        }),
+    },
+  )
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -95,13 +99,11 @@ test("retries runtime add for file plugins after dependency wait", async () => {
 
     await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
     await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
-    expect(wait).toHaveBeenCalledTimes(1)
     expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 4 - 5
packages/opencode/test/cli/tui/plugin-install.test.ts

@@ -5,6 +5,7 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -50,12 +51,11 @@ test("installs plugin without loading it", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
+  const cfg: TuiConfig.Info = {
     plugin: [],
     plugin_origins: undefined,
   }
-  const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const restore = mockTuiService(cfg)
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi({
     state: {
@@ -82,8 +82,7 @@ test("installs plugin without loading it", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 0 - 1
packages/opencode/test/cli/tui/plugin-lifecycle.test.ts

@@ -5,7 +5,6 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { mockTuiRuntime } from "../../fixture/tui-runtime"
-import { TuiConfig } from "../../../src/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 

+ 17 - 33
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 import { Npm } from "../../../src/npm"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -54,7 +54,6 @@ test("loads npm tui plugin from package ./tui export", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
@@ -69,8 +68,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -106,7 +104,7 @@ test("does not use npm package exports dot for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -116,7 +114,6 @@ test("does not use npm package exports dot for tui entry", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
@@ -128,8 +125,7 @@ test("does not use npm package exports dot for tui entry", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -169,7 +165,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -179,7 +175,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
@@ -193,8 +188,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -232,7 +226,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -242,7 +236,6 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
@@ -254,8 +247,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -291,7 +283,7 @@ test("does not use npm package main for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -301,7 +293,6 @@ test("does not use npm package main for tui entry", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
   const warn = spyOn(console, "warn").mockImplementation(() => {})
@@ -317,8 +308,7 @@ test("does not use npm package main for tui entry", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     warn.mockRestore()
     error.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
@@ -357,7 +347,7 @@ test("does not use directory package main for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -367,7 +357,6 @@ test("does not use directory package main for tui entry", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -377,8 +366,7 @@ test("does not use directory package main for tui entry", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -405,7 +393,7 @@ test("uses directory index fallback for tui when package.json is missing", async
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -415,7 +403,6 @@ test("uses directory index fallback for tui when package.json is missing", async
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -425,8 +412,7 @@ test("uses directory index fallback for tui when package.json is missing", async
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -463,7 +449,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -473,7 +459,6 @@ test("uses npm package name when tui plugin id is omitted", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
@@ -485,8 +470,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 3 - 5
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => {
   process.env.OPENCODE_PURE = "1"
   process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
 
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -47,7 +47,6 @@ test("skips external tui plugins in pure mode", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -56,8 +55,7 @@ test("skips external tui plugins in pure mode", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     if (pure === undefined) {
       delete process.env.OPENCODE_PURE
     } else {

+ 62 - 9
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -4,8 +4,8 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { mockTuiService } from "../../fixture/tui-runtime"
 import { Global } from "../../../src/global"
-import { TuiConfig } from "../../../src/config/tui"
 import { Filesystem } from "../../../src/util/filesystem"
 
 const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -322,8 +322,59 @@ export default {
       }
     },
   })
+  const localConfigPath = path.join(tmp.path, "tui.json")
+  const globalPlugin: [string, Record<string, unknown>] = [
+    tmp.extra.globalSpec,
+    {
+      marker: tmp.extra.globalMarker,
+      theme_path: `./${tmp.extra.globalThemeFile}`,
+      theme_name: tmp.extra.globalThemeName,
+    },
+  ]
+  const localPlugins: [string, Record<string, unknown>][] = [
+    [
+      tmp.extra.localSpec,
+      {
+        fn_marker: tmp.extra.fnMarker,
+        marker: tmp.extra.localMarker,
+        source: path.join(tmp.path, tmp.extra.localThemeFile),
+        dest: tmp.extra.localDest,
+        theme_path: `./${tmp.extra.localThemeFile}`,
+        theme_name: tmp.extra.localThemeName,
+        kv_key: "plugin_state_key",
+        session_id: "ses_test",
+        keybinds: {
+          modal: "ctrl+alt+m",
+          close: "q",
+        },
+      },
+    ],
+    [
+      tmp.extra.invalidSpec,
+      {
+        marker: tmp.extra.invalidMarker,
+        theme_path: `./${tmp.extra.invalidThemeFile}`,
+        theme_name: tmp.extra.invalidThemeName,
+      },
+    ],
+    [
+      tmp.extra.preloadedSpec,
+      {
+        marker: tmp.extra.preloadedMarker,
+        dest: tmp.extra.preloadedDest,
+        theme_path: `./${tmp.extra.preloadedThemeFile}`,
+        theme_name: tmp.extra.preloadedThemeName,
+      },
+    ],
+  ]
+  const restore = mockTuiService({
+    plugin: [globalPlugin, ...localPlugins],
+    plugin_origins: [
+      { spec: globalPlugin, scope: "global", source: globalConfigPath },
+      ...localPlugins.map((spec) => ({ spec, scope: "local" as const, source: localConfigPath })),
+    ],
+  })
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
 
   try {
     expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -404,7 +455,7 @@ export default {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    wait.mockRestore()
+    restore()
     if (backup === undefined) {
       await fs.rm(globalConfigPath, { force: true })
     } else {
@@ -459,7 +510,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [
       [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
       [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -478,7 +529,6 @@ test("continues loading when a plugin is missing config metadata", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
   try {
@@ -492,8 +542,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -696,8 +745,12 @@ test("updates installed theme when plugin metadata changes", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
+  const plugin: [string, Record<string, unknown>] = [tmp.extra.spec, { theme_path: "./theme-update.json" }]
+  const restore = mockTuiService({
+    plugin: [plugin],
+    plugin_origins: [{ spec: plugin, scope: "local", source: path.join(tmp.path, "tui.json") }],
+  })
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
 
   const api = () =>
     createTuiPluginApi({
@@ -741,7 +794,7 @@ test("updates installed theme when plugin metadata changes", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 5 - 9
packages/opencode/test/cli/tui/plugin-toggle.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
 
@@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.toggle": false,
@@ -52,7 +52,6 @@ test("toggles plugin runtime state by exported id", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()
 
@@ -85,8 +84,7 @@ test("toggles plugin runtime state by exported id", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })
@@ -117,7 +115,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
+  const restore = mockTuiService({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.startup": false,
@@ -130,7 +128,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
       },
     ],
   })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()
   api.kv.set("plugin_enabled", {
@@ -152,8 +149,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   } finally {
     await TuiPluginRuntime.dispose()
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 2 - 2
packages/opencode/test/cli/tui/thread.test.ts

@@ -8,7 +8,7 @@ import { UI } from "../../../src/cli/ui"
 import * as Timeout from "../../../src/util/timeout"
 import * as Network from "../../../src/cli/network"
 import * as Win32 from "../../../src/cli/cmd/tui/win32"
-import { TuiConfig } from "../../../src/config/tui"
+import { mockTuiService } from "../../fixture/tui-runtime"
 import { Instance } from "../../../src/project/instance"
 
 const stop = new Error("stop")
@@ -42,7 +42,7 @@ function setup() {
   })
   spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
   spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
-  spyOn(TuiConfig, "get").mockResolvedValue({})
+  mockTuiService({})
   spyOn(Instance, "provide").mockImplementation(async (input) => {
     seen.inst.push(input.directory)
     return input.fn()

+ 29 - 28
packages/opencode/test/config/tui.test.ts

@@ -13,6 +13,7 @@ const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 const wintest = process.platform === "win32" ? test : test.skip
 const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
 const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
+const tuiGet = () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get()))
 
 beforeEach(async () => {
   await clear(true)
@@ -83,7 +84,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
     directory: tmp.path,
     fn: async () => {
       const server = await load()
-      const tui = await TuiConfig.get()
+      const tui = await tuiGet()
       const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
       const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
 
@@ -116,7 +117,7 @@ test("loads tui config with the same precedence order as server config paths", a
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("local")
       expect(config.diff_style).toBe("stacked")
     },
@@ -144,7 +145,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("migrated-theme")
       expect(config.scroll_speed).toBe(5)
       expect(config.keybinds?.app_exit).toBe("ctrl+q")
@@ -184,7 +185,7 @@ test("migrates project legacy tui keys even when global tui.json already exists"
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("project-migrated")
       expect(config.scroll_speed).toBe(2)
       expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
@@ -216,7 +217,7 @@ test("drops unknown legacy tui keys during migration", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("migrated-theme")
       expect(config.scroll_speed).toBe(2)
 
@@ -245,7 +246,7 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () =>
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBeUndefined()
       expect(config.scroll_speed).toBeUndefined()
       expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
@@ -268,7 +269,7 @@ test("skips migration when tui.json already exists", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.diff_style).toBe("stacked")
       expect(config.theme).toBeUndefined()
 
@@ -293,7 +294,7 @@ test("continues loading tui config when legacy source cannot be stripped", async
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await TuiConfig.get()
+        const config = await tuiGet()
         expect(config.theme).toBe("readonly-theme")
         expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
 
@@ -326,7 +327,7 @@ test("migration backup preserves JSONC comments", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await TuiConfig.get()
+      await tuiGet()
       const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
       expect(backup).toContain("// top-level comment")
       expect(backup).toContain("// nested comment")
@@ -349,7 +350,7 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () =
   await Instance.provide({
     directory: path.join(tmp.path, "apps", "client"),
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("nested-theme")
       expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
       expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
@@ -373,7 +374,7 @@ test("flattens nested tui key inside tui.json", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.scroll_speed).toBe(3)
       expect(config.diff_style).toBe("stacked")
       // top-level keys take precedence over nested tui keys
@@ -398,7 +399,7 @@ test("top-level keys in tui.json take precedence over nested tui key", async ()
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.diff_style).toBe("auto")
       expect(config.scroll_speed).toBe(2)
     },
@@ -418,7 +419,7 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       // project tui.json overrides the custom path, same as server config precedence
       expect(config.theme).toBe("project")
       // project also set diff_style, so that wins
@@ -438,7 +439,7 @@ test("merges keybind overrides across precedence layers", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.keybinds?.app_exit).toBe("ctrl+q")
       expect(config.keybinds?.theme_list).toBe("ctrl+k")
     },
@@ -451,7 +452,7 @@ wintest("defaults Ctrl+Z to input undo on Windows", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.keybinds?.terminal_suspend).toBe("none")
       expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
     },
@@ -468,7 +469,7 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.keybinds?.terminal_suspend).toBe("none")
       expect(config.keybinds?.input_undo).toBe("ctrl+y")
     },
@@ -485,7 +486,7 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.keybinds?.terminal_suspend).toBe("none")
       expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
     },
@@ -504,7 +505,7 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("from-env")
       expect(config.diff_style).toBe("stacked")
     },
@@ -525,7 +526,7 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBeUndefined()
     },
   })
@@ -551,7 +552,7 @@ test("applies env and file substitutions in tui.json", async () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await TuiConfig.get()
+        const config = await tuiGet()
         expect(config.theme).toBe("env-theme")
         expect(config.keybinds?.app_exit).toBe("ctrl+q")
       },
@@ -579,7 +580,7 @@ test("applies file substitutions when first identical token is in a commented li
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("resolved-theme")
     },
   })
@@ -603,7 +604,7 @@ test("loads managed tui config and gives it highest precedence", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("managed-theme")
       expect(config.plugin).toEqual(["[email protected]"])
       expect(config.plugin_origins).toEqual([
@@ -628,7 +629,7 @@ test("loads .opencode/tui.json", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.diff_style).toBe("stacked")
     },
   })
@@ -646,7 +647,7 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.theme).toBe("managed-fallback")
       expect(config.keybinds).toBeDefined()
     },
@@ -668,7 +669,7 @@ test("supports tuple plugin specs with options in tui.json", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
       expect(config.plugin_origins).toEqual([
         {
@@ -705,7 +706,7 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.plugin).toEqual([
         ["[email protected]", { source: "project" }],
         ["[email protected]", { source: "project" }],
@@ -747,7 +748,7 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
       expect(config.plugin_origins).toEqual([
         {
@@ -792,7 +793,7 @@ test("merges plugin_enabled flags across config layers", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await TuiConfig.get()
+      const config = await tuiGet()
       expect(config.plugin_enabled).toEqual({
         "internal:sidebar-context": false,
         "demo.plugin": false,

+ 24 - 7
packages/opencode/test/fixture/tui-runtime.ts

@@ -1,9 +1,31 @@
 import { spyOn } from "bun:test"
 import path from "path"
+import { Effect } from "effect"
 import { TuiConfig } from "../../src/config/tui"
 
 type PluginSpec = string | [string, Record<string, unknown>]
 
+/**
+ * Mock `TuiConfig.Service.use` so callers that do
+ * `AppRuntime.runPromise(TuiConfig.Service.use(svc => svc.get()))` receive
+ * the provided config object instead of loading from disk.
+ *
+ * Returns a restore function.
+ */
+export function mockTuiService(config: TuiConfig.Info, opts?: { wait?: () => Effect.Effect<void> }) {
+  const mock: TuiConfig.Interface = {
+    get: () => Effect.succeed(config),
+    waitForDependencies: () => opts?.wait?.() ?? Effect.void,
+  }
+  const spy = spyOn(TuiConfig.Service, "use" as never).mockImplementation(((fn: (svc: TuiConfig.Interface) => any) =>
+    fn(mock)) as never)
+  return () => spy.mockRestore()
+}
+
+/**
+ * Full mock: sets OPENCODE_PLUGIN_META_FILE, mocks cwd, and mocks
+ * TuiConfig.Service with the given plugins.
+ */
 export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
   const plugin_origins = plugin.map((spec) => ({
@@ -11,17 +33,12 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
     scope: "local" as const,
     source: path.join(dir, "tui.json"),
   }))
-  const get = spyOn(TuiConfig, "get").mockResolvedValue({
-    plugin,
-    plugin_origins,
-  })
-  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const restore = mockTuiService({ plugin, plugin_origins })
   const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
 
   return () => {
     cwd.mockRestore()
-    get.mockRestore()
-    wait.mockRestore()
+    restore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 }