Explorar el Código

refactor: route Global path consumers through the service (#41632)

Kit Langton hace 5 días
padre
commit
41c5853739
Se han modificado 32 ficheros con 220 adiciones y 136 borrados
  1. 3 0
      packages/cli/src/commands/handlers/mini.ts
  2. 2 7
      packages/cli/src/mini-host.ts
  3. 2 1
      packages/cli/src/mini.ts
  4. 2 1
      packages/cli/src/server-process.ts
  5. 11 2
      packages/core/src/command.ts
  6. 10 7
      packages/core/src/database/database.ts
  7. 2 1
      packages/core/src/database/migration.ts
  8. 4 1
      packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts
  9. 7 6
      packages/core/src/database/v1-migration.ts
  10. 4 1
      packages/core/src/formatter.ts
  11. 34 26
      packages/core/src/formatter/builtins.ts
  12. 3 2
      packages/core/src/models-dev.ts
  13. 9 2
      packages/core/src/pty.ts
  14. 11 7
      packages/core/src/ripgrep/binary.ts
  15. 3 1
      packages/core/src/shell.ts
  16. 51 36
      packages/core/src/shell/select.ts
  17. 2 3
      packages/core/src/util/which.ts
  18. 18 6
      packages/core/test/database-migration.test.ts
  19. 1 2
      packages/core/test/session-create.test.ts
  20. 10 4
      packages/core/test/v1-migration.test.ts
  21. 1 0
      packages/server/src/routes.ts
  22. 5 2
      packages/tui/src/app.tsx
  23. 5 6
      packages/tui/src/context/theme.tsx
  24. 2 1
      packages/tui/test/cli/tui/command-palette.test.tsx
  25. 2 1
      packages/tui/test/cli/tui/data.test.tsx
  26. 2 1
      packages/tui/test/cli/tui/dialog-open.test.tsx
  27. 2 2
      packages/tui/test/cli/tui/dialog-prompt.test.tsx
  28. 3 3
      packages/tui/test/cli/tui/dialog-select.test.tsx
  29. 2 1
      packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
  30. 2 1
      packages/tui/test/cli/tui/diff-viewer.test.tsx
  31. 2 2
      packages/tui/test/cli/tui/form.test.tsx
  32. 3 0
      packages/tui/test/fixture/fixture.ts

+ 3 - 0
packages/cli/src/commands/handlers/mini.ts

@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
 import { ServerConnection } from "../../services/server-connection"
 import { Config } from "../../config"
 import { resolve } from "@opencode-ai/tui/config"
+import { Global } from "@opencode-ai/util/global"
 
 export default Runtime.handler(Commands.commands.mini, (input) =>
   Effect.gen(function* () {
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
       mismatch: "replace",
     })
     const config = yield* Config.Service
+    const global = yield* Global.Service
     const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
     const fileSystem = yield* FileSystem.FileSystem
     const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
         config: {
           update: (update) => runServicePromise(config.update(update)),
         },
+        paths: { home: global.home, state: global.state, log: global.log },
       }),
     )
   }),

+ 2 - 7
packages/cli/src/mini-host.ts

@@ -1,6 +1,5 @@
 import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
 import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
-import { Global } from "@opencode-ai/util/global"
 import fs from "node:fs"
 import { readFile } from "node:fs/promises"
 import path from "node:path"
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
 export function createMiniHost(input: {
   terminal: InteractiveStdin
   directory: string
-  paths?: { home: string; state: string; log: string }
+  paths: { home: string; state: string; log: string }
 }): MiniHost {
-  const paths = input.paths ?? {
-    home: Global.Path.home,
-    state: Global.Path.state,
-    log: Global.Path.log,
-  }
+  const paths = input.paths
   const diagnostics = {
     pid: process.pid,
     cwd: input.directory,

+ 2 - 1
packages/cli/src/mini.ts

@@ -22,6 +22,7 @@ export type MiniCommandInput = {
   demo?: boolean
   tuiConfig?: MiniFrontendInput["tuiConfig"]
   config?: MiniFrontendInput["config"]
+  paths: { home: string; state: string; log: string }
 }
 
 type Model = MiniFrontendInput["model"]
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
         }))
       const frontend = await frontendTask
       return frontend.runMiniFrontend({
-        host: createMiniHost({ terminal, directory }),
+        host: createMiniHost({ terminal, directory, paths: input.paths }),
         sdk,
         directory,
         target: resolveTarget,

+ 2 - 1
packages/cli/src/server-process.ts

@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
 })
 
 const processEffect = Effect.fnUntraced(function* (options: Options) {
-  if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
+  const global = yield* Global.Service
+  if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
   return yield* Effect.scoped(
     Effect.gen(function* () {
       const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined

+ 11 - 2
packages/core/src/command.ts

@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
 import { Config } from "./config"
 import { Location } from "./location"
 import { ShellSelect } from "./shell/select"
+import { Global } from "@opencode-ai/util/global"
 
 export const Info = Command.Info
 export type Info = Command.Info
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
       const processes = yield* AppProcess.Service
       const config = yield* Config.Service
       const location = yield* Location.Service
+      const global = yield* Global.Service
       const state = State.create<Data, Draft>({
         name: "command",
         initial: () => ({ commands: new Map() }),
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
               location,
               processes,
               shell: options,
+              bin: global.bin,
             })
 
           const prompt = (yield* mcp.prompts()).find(
@@ -164,6 +167,7 @@ function evaluateTemplate(
     readonly location: Location.Info
     readonly processes: AppProcess.Interface
     readonly shell?: ShellSelect.Options
+    readonly bin: string
   },
 ) {
   return Effect.gen(function* () {
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
     readonly location: Location.Info
     readonly processes: AppProcess.Interface
     readonly shell?: ShellSelect.Options
+    readonly bin: string
   },
 ) {
   const matches = Array.from(text.matchAll(shellRegex))
   if (matches.length === 0) return text
-  const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
+  const shell = ShellSelect.preferred(
+    Config.latest(yield* services.config.entries(), "shell"),
+    services.shell,
+    services.bin,
+  )
   const outputs = yield* Effect.forEach(
     matches,
     (match) => {
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
   return makeLocationNode({
     service: Service,
     layer: layer(options),
-    deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
+    deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
   })
 }
 

+ 10 - 7
packages/core/src/database/database.ts

@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
 )
 
 export function layer(options: Options = { path: ":memory:" }) {
-  return Layer.suspend(() => {
-    const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
-    const filename = options.path ?? ":memory:"
-    if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
-    return provide(join(Global.Path.data, filename))
-  })
+  return Layer.unwrap(
+    Effect.gen(function* () {
+      const global = yield* Global.Service
+      const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
+      const filename = options.path ?? ":memory:"
+      if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
+      return provide(join(global.data, filename))
+    }),
+  )
 }
 
 export function configured(options?: Options) {
-  return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
+  return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
 }
 
 export const node = configured({ path: ":memory:" })

+ 2 - 1
packages/core/src/database/migration.ts

@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
 import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
 import { migrations } from "./migration.gen"
 import schema from "./schema.gen"
+import { Global } from "@opencode-ai/util/global"
 
 type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
 type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
@@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1)
 export type Migration = {
   id: string
   foreignKeys?: boolean
-  up: (tx: Transaction) => Effect.Effect<void, unknown>
+  up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
 }
 
 export function apply(db: Database) {

+ 4 - 1
packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts

@@ -34,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
 const migration: DatabaseMigration.Migration = {
   id: "20260805200742_import_legacy_credentials",
   up(tx) {
-    return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
+    return Effect.gen(function* () {
+      const global = yield* Global.Service
+      return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
+    })
   },
 }
 

+ 7 - 6
packages/core/src/database/v1-migration.ts

@@ -467,10 +467,11 @@ function updateProgress(progress: Progress) {
   if (runtimeState.status === "running") runtimeState = { status: "running", progress }
 }
 
-export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
+export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
   return lock.withPermit(
     Effect.gen(function* () {
       const { db } = yield* Database.Service
+      const global = yield* Global.Service
       const state = yield* readState(db)
       if (state?.phase === "completed") return { status: "completed" as const }
       if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
@@ -478,7 +479,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
         const now = Date.now()
         yield* db.run(sql`
           INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
-          VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
+          VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
         `)
         if (state === undefined)
           yield* db
@@ -492,7 +493,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
               }),
             )
             .pipe(Effect.orDie)
-        const sourceTotal = yield* countNextSessions(nextPath(options))
+        const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
         const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
         const cursor = state?.phase === "sessions" ? state.cursor : undefined
         const migrated =
@@ -502,7 +503,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
             : 0
         const denominator = sourceTotal + legacyTotal
         updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
-        yield* importNextDatabase(db, nextPath(options), (completed) => {
+        yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
           updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
         })
         updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
@@ -621,10 +622,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
   )
 }
 
-function nextPath(options: Options) {
+function nextPath(options: Options, data: string) {
   if (options.nextDatabasePath) return options.nextDatabasePath
   if (process.env.OPENCODE_DB === ":memory:") return undefined
-  return path.join(Global.Path.data, "opencode-next.db")
+  return path.join(data, "opencode-next.db")
 }
 
 function openNextDatabase(sourcePath: string) {

+ 4 - 1
packages/core/src/formatter.ts

@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Npm } from "@opencode-ai/util/npm"
 import { AppProcess } from "@opencode-ai/util/process"
+import { Global } from "@opencode-ai/util/global"
 import { Config } from "./config"
 import { Location } from "./location"
 import { make, type Info } from "./formatter/builtins"
@@ -25,6 +26,7 @@ const layer = Layer.effect(
     const location = yield* Location.Service
     const npm = yield* Npm.Service
     const processes = yield* AppProcess.Service
+    const global = yield* Global.Service
     const commands = new Map<string, string[] | false>()
     let formatters: Info[] = []
 
@@ -42,6 +44,7 @@ const layer = Layer.effect(
           fs,
           npm,
           processes,
+          bin: global.bin,
         })
         formatters = builtIns
         if (configured === true) return
@@ -122,5 +125,5 @@ const layer = Layer.effect(
 export const node = makeLocationNode({
   service: Service,
   layer,
-  deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
+  deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
 })

+ 34 - 26
packages/core/src/formatter/builtins.ts

@@ -18,8 +18,10 @@ export function make(input: {
   readonly fs: FSUtil.Interface
   readonly npm: Npm.Interface
   readonly processes: AppProcess.Interface
+  readonly bin: string
 }) {
   const disabled = false as const
+  const findExecutable = (name: string) => which(name, undefined, input.bin)
   const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
   const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
   const commandOutput = (command: string[]) =>
@@ -37,7 +39,7 @@ export function make(input: {
     name: "gofmt",
     extensions: [".go"],
     enabled: Effect.sync(() => {
-      const match = which("gofmt")
+      const match = findExecutable("gofmt")
       return match ? [match, "-w", "$FILE"] : disabled
     }),
   }
@@ -46,7 +48,7 @@ export function make(input: {
     name: "mix",
     extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
     enabled: Effect.sync(() => {
-      const match = which("mix")
+      const match = findExecutable("mix")
       return match ? [match, "format", "$FILE"] : disabled
     }),
   }
@@ -149,7 +151,7 @@ export function make(input: {
     name: "zig",
     extensions: [".zig", ".zon"],
     enabled: Effect.sync(() => {
-      const match = which("zig")
+      const match = findExecutable("zig")
       return match ? [match, "fmt", "$FILE"] : disabled
     }),
   }
@@ -159,7 +161,7 @@ export function make(input: {
     extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
     enabled: Effect.gen(function* () {
       if (!(yield* findUp(".clang-format")).length) return disabled
-      const match = which("clang-format")
+      const match = findExecutable("clang-format")
       return match ? [match, "-i", "$FILE"] : disabled
     }).pipe(Effect.orElseSucceed(() => disabled)),
   }
@@ -168,7 +170,7 @@ export function make(input: {
     name: "ktlint",
     extensions: [".kt", ".kts"],
     enabled: Effect.sync(() => {
-      const match = which("ktlint")
+      const match = findExecutable("ktlint")
       return match ? [match, "-F", "$FILE"] : disabled
     }),
   }
@@ -177,17 +179,18 @@ export function make(input: {
     name: "ruff",
     extensions: [".py", ".pyi"],
     enabled: Effect.gen(function* () {
-      if (!which("ruff")) return disabled
+      const bin = findExecutable("ruff")
+      if (!bin) return disabled
       for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
         const found = yield* findUp(config)
         if (!found.length) continue
         if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
-          return ["ruff", "format", "$FILE"]
+          return [bin, "format", "$FILE"]
         }
       }
       for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
         const found = yield* findUp(dependency)
-        if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
+        if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
       }
       return disabled
     }).pipe(Effect.orElseSucceed(() => disabled)),
@@ -197,7 +200,7 @@ export function make(input: {
     name: "air",
     extensions: [".R"],
     enabled: Effect.gen(function* () {
-      const bin = which("air")
+      const bin = findExecutable("air")
       if (!bin) return disabled
       const output = yield* commandOutput([bin, "--help"])
       if (output._tag === "None" || output.value.exitCode !== 0) return disabled
@@ -210,34 +213,34 @@ export function make(input: {
     name: "uv",
     extensions: [".py", ".pyi"],
     enabled: Effect.gen(function* () {
-      const bin = which("uv")
+      const bin = findExecutable("uv")
       if (!bin) return disabled
       const output = yield* commandOutput([bin, "format", "--help"])
       return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
     }),
   }
 
-  const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
-  const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
-  const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
-  const dart = executable("dart", [".dart"], ["format", "$FILE"])
+  const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
+  const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
+  const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
+  const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
 
   const ocamlformat: Info = {
     name: "ocamlformat",
     extensions: [".ml", ".mli"],
     enabled: Effect.gen(function* () {
       if (!(yield* findUp(".ocamlformat")).length) return disabled
-      const match = which("ocamlformat")
+      const match = findExecutable("ocamlformat")
       return match ? [match, "-i", "$FILE"] : disabled
     }).pipe(Effect.orElseSucceed(() => disabled)),
   }
 
-  const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
-  const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
-  const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
-  const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
-  const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
-  const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
+  const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
+  const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
+  const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
+  const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
+  const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
+  const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
 
   const pint: Info = {
     name: "pint",
@@ -253,9 +256,9 @@ export function make(input: {
     }).pipe(Effect.orElseSucceed(() => disabled)),
   }
 
-  const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
-  const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
-  const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
+  const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
+  const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
+  const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
 
   return [
     gofmt,
@@ -287,12 +290,17 @@ export function make(input: {
   ] satisfies Info[]
 }
 
-function executable(name: string, extensions: readonly string[], args: string[]): Info {
+function executable(
+  name: string,
+  extensions: readonly string[],
+  args: string[],
+  findExecutable: (name: string) => string | null,
+): Info {
   return {
     name,
     extensions,
     enabled: Effect.sync(() => {
-      const match = which(name)
+      const match = findExecutable(name)
       return match ? [match, ...args] : false
     }),
   }

+ 3 - 2
packages/core/src/models-dev.ts

@@ -544,6 +544,7 @@ export const layer = (options?: Options) =>
       const fs = yield* FSUtil.Service
       const bus = yield* Bus.Service
       const app = yield* App.Metadata
+      const global = yield* Global.Service
       const http = HttpClient.filterStatusOk(
         (yield* HttpClient.HttpClient).pipe(
           HttpClient.retryTransient({
@@ -558,7 +559,7 @@ export const layer = (options?: Options) =>
       const fetch = options?.fetch ?? true
       const userAgent = App.useragent(app)
       const filepath = path.join(
-        Global.Path.cache,
+        global.cache,
         source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
       )
       const ttl = Duration.minutes(5)
@@ -660,7 +661,7 @@ export function configured(options?: Options) {
   return makeGlobalNode({
     service: Service,
     layer: layer(options),
-    deps: [FSUtil.node, Bus.node, App.node, httpClient],
+    deps: [FSUtil.node, Bus.node, App.node, Global.node, httpClient],
   })
 }
 

+ 9 - 2
packages/core/src/pty.ts

@@ -9,6 +9,7 @@ import { Bus } from "./bus"
 import { Location } from "./location"
 import { PtyID } from "./pty/schema"
 import { ShellSelect } from "./shell/select"
+import { Global } from "@opencode-ai/util/global"
 import { lazy } from "./util/lazy"
 
 const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) =>
       const bus = yield* Bus.Service
       const location = yield* Location.Service
       const config = yield* Config.Service
+      const global = yield* Global.Service
       const context = yield* Effect.context()
       const runFork = Effect.runForkWith(context)
       const sessions = new Map<PtyID, Active>()
@@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) =>
 
       const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
         const id = PtyID.ascending()
-        const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
+        const command =
+          input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
         const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
         const cwd = input.cwd || location.directory
         const env = {
@@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) =>
   )
 
 export function configured(options?: ShellSelect.Options) {
-  return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
+  return makeLocationNode({
+    service: Service,
+    layer: layer(options),
+    deps: [Bus.node, Location.node, Config.node, Global.node],
+  })
 }
 
 export const node = configured()

+ 11 - 7
packages/core/src/ripgrep/binary.ts

@@ -34,6 +34,8 @@ export namespace RipgrepBinary {
       const fs = yield* FSUtil.Service
       const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
       const spawner = yield* ChildProcessSpawner
+      const global = yield* Global.Service
+      const findExecutable = (name: string) => which(name, undefined, global.bin)
 
       const run = Effect.fnUntraced(function* (command: string, args: string[]) {
         const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
@@ -53,10 +55,12 @@ export namespace RipgrepBinary {
         config: (typeof PLATFORM)[keyof typeof PLATFORM],
         target: string,
       ) {
-        const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
+        const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
 
         if (config.extension === "zip") {
-          const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
+          const shell =
+            (yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
+            "powershell.exe"
           const result = yield* run(shell, [
             "-NoProfile",
             "-NonInteractive",
@@ -91,10 +95,10 @@ export namespace RipgrepBinary {
       return Service.of({
         filepath: yield* Effect.cached(
           Effect.gen(function* () {
-            const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
+            const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
             if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
 
-            const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
+            const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
             if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
 
             const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
@@ -103,10 +107,10 @@ export namespace RipgrepBinary {
 
             const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
             const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
-            const archive = path.join(Global.Path.bin, filename)
+            const archive = path.join(global.bin, filename)
 
             yield* Effect.logInfo("downloading ripgrep", { url })
-            yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
+            yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
             const bytes = yield* HttpClientRequest.get(url).pipe(
               http.execute,
               Effect.flatMap((response) => response.arrayBuffer),
@@ -127,6 +131,6 @@ export namespace RipgrepBinary {
   export const node = makeGlobalNode({
     service: Service,
     layer: layer,
-    deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
+    deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
   })
 }

+ 3 - 1
packages/core/src/shell.ts

@@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) =>
       })
 
       const resolve = () =>
-        config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
+        config
+          .entries()
+          .pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
 
       const name = () => resolve().pipe(Effect.map(ShellSelect.name))
 

+ 51 - 36
packages/core/src/shell/select.ts

@@ -34,15 +34,19 @@ function stat(file: string) {
   return statSync(file, { throwIfNoEntry: false }) ?? undefined
 }
 
-function full(file: string, options?: Options) {
+function findExecutable(name: string, bin?: string) {
+  return which(name, undefined, bin)
+}
+
+function full(file: string, options?: Options, bin?: string) {
   if (process.platform !== "win32") return file
   const shell = FSUtil.windowsPath(file)
   if (path.win32.dirname(shell) !== ".") {
-    if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
+    if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
     return shell
   }
-  if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
-  return which(shell) || shell
+  if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
+  return findExecutable(shell, bin) || shell
 }
 
 function meta(file: string) {
@@ -57,21 +61,26 @@ function rooted(file: string) {
   return path.isAbsolute(FSUtil.windowsPath(file))
 }
 
-function resolve(file: string, options?: Options) {
-  const shell = full(file, options)
+function resolve(file: string, options?: Options, bin?: string) {
+  const shell = full(file, options, bin)
   if (rooted(shell)) {
     if (stat(shell)?.isFile()) return shell
     return
   }
-  return which(shell) ?? undefined
+  return findExecutable(shell, bin) ?? undefined
 }
 
-function win(options?: Options) {
+function win(options?: Options, bin?: string) {
   return Array.from(
     new Set(
-      [which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
+      [
+        findExecutable("pwsh", bin),
+        findExecutable("powershell", bin),
+        gitbash(options, bin),
+        process.env.COMSPEC || "cmd.exe",
+      ]
         .filter((item): item is string => Boolean(item))
-        .map((file) => full(file, options)),
+        .map((file) => full(file, options, bin)),
     ),
   )
 }
@@ -82,27 +91,27 @@ async function unix() {
   return ["/bin/bash", "/bin/zsh", "/bin/sh"]
 }
 
-function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
+function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
   if (file && (!opts?.acceptable || ok(file))) {
-    const shell = resolve(file, options)
+    const shell = resolve(file, options, bin)
     if (shell) return shell
   }
-  if (process.platform === "win32") return win(options)[0]
-  return fallback()
+  if (process.platform === "win32") return win(options, bin)[0]
+  return fallback(bin)
 }
 
-export function gitbash(options?: Options) {
+export function gitbash(options?: Options, bin?: string) {
   if (process.platform !== "win32") return
   if (options?.gitbash) return options.gitbash
-  const git = which("git")
+  const git = findExecutable("git", bin)
   if (!git) return
   const file = path.join(git, "..", "..", "bin", "bash.exe")
   if (stat(file)?.size) return file
 }
 
-function fallback() {
+function fallback(bin?: string) {
   if (process.platform === "darwin") return "/bin/zsh"
-  const bash = which("bash")
+  const bash = findExecutable("bash", bin)
   if (bash) return bash
   return "/bin/sh"
 }
@@ -120,12 +129,12 @@ export function ps(file: string) {
   return meta(file)?.ps === true
 }
 
-function info(file: string, options?: Options): Item {
-  const item = full(file, options)
+function info(file: string, options?: Options, bin?: string): Item {
+  const item = full(file, options, bin)
   const n = name(item)
   return {
     path: item,
-    name: resolve(n, options) ? n : item,
+    name: resolve(n, options, bin) ? n : item,
     acceptable: ok(item),
   }
 }
@@ -139,30 +148,36 @@ export function args(file: string, command: string) {
   return ["-c", command]
 }
 
-let defaultPreferred: string | undefined
-let defaultAcceptable: string | undefined
+let defaultPreferred: { bin?: string; value: string } | undefined
+let defaultAcceptable: { bin?: string; value: string } | undefined
 
-export function preferred(configShell?: string, options?: Options) {
-  if (configShell) return select(configShell, options)
-  if (options?.gitbash) return select(process.env.SHELL, options)
-  defaultPreferred ??= select(process.env.SHELL)
-  return defaultPreferred
+export function preferred(configShell?: string, options?: Options, bin?: string) {
+  if (configShell) return select(configShell, options, undefined, bin)
+  if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
+  const cached = defaultPreferred
+  if (cached && cached.bin === bin) return cached.value
+  const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
+  defaultPreferred = { bin, value }
+  return value
 }
 preferred.reset = () => {
   defaultPreferred = undefined
 }
 
-export function acceptable(configShell?: string, options?: Options) {
-  if (configShell) return select(configShell, options, { acceptable: true })
-  if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
-  defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
-  return defaultAcceptable
+export function acceptable(configShell?: string, options?: Options, bin?: string) {
+  if (configShell) return select(configShell, options, { acceptable: true }, bin)
+  if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
+  const cached = defaultAcceptable
+  if (cached && cached.bin === bin) return cached.value
+  const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
+  defaultAcceptable = { bin, value }
+  return value
 }
 acceptable.reset = () => {
   defaultAcceptable = undefined
 }
 
-export async function list(options?: Options): Promise<Item[]> {
-  const shells = process.platform === "win32" ? win(options) : await unix()
-  return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
+export async function list(options?: Options, bin?: string): Promise<Item[]> {
+  const shells = process.platform === "win32" ? win(options, bin) : await unix()
+  return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
 }

+ 2 - 3
packages/core/src/util/which.ts

@@ -1,10 +1,9 @@
 import whichPkg from "which"
 import path from "path"
-import { Global } from "@opencode-ai/util/global"
 
-export function which(cmd: string, env?: NodeJS.ProcessEnv) {
+export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
   const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
-  const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
+  const full = base && bin ? base + path.delimiter + bin : base || bin
   const result = whichPkg.sync(cmd, {
     nothrow: true,
     path: full,

+ 18 - 6
packages/core/test/database-migration.test.ts

@@ -11,11 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
 import { Database } from "@opencode-ai/core/database/database"
 import { tmpdir } from "./fixture/tmpdir"
 import type { SqlClient } from "effect/unstable/sql/SqlClient"
-import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
+import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
+import { Global } from "@opencode-ai/util/global"
 
-const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
+const run = <A, E>(
+  effect: Effect.Effect<A, E, SqlClient | Global.Service>,
+  global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
+) =>
   Effect.runPromise(
-    effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
+    effect.pipe(
+      Effect.provideService(Global.Service, global),
+      Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
+      Effect.scoped,
+    ),
   )
 
 const makeDb = EffectDrizzleSqlite.makeWithDefaults()
@@ -31,7 +39,7 @@ describe("DatabaseMigration", () => {
           Effect.scoped(Layer.build(layer)),
         ),
         { concurrency: "unbounded" },
-      ),
+      ).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
     )
   })
 
@@ -127,7 +135,8 @@ describe("DatabaseMigration", () => {
           VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
         `)
 
-        yield* db.transaction((tx) => importLegacyCredentials(tx, source))
+        yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
+        yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
 
         expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
           [
@@ -159,6 +168,7 @@ describe("DatabaseMigration", () => {
           value: JSON.stringify(["https://example.com"]),
         })
       }),
+      Global.make({ data: tmp.path }),
     )
 
     expect(await Bun.file(source).text()).toBe(content)
@@ -171,10 +181,12 @@ describe("DatabaseMigration", () => {
       Effect.gen(function* () {
         const db = yield* makeDb
         yield* DatabaseMigration.apply(db)
-        yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json")))
+        yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
+        yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
 
         expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
       }),
+      Global.make({ data: tmp.path }),
     )
   })
 

+ 1 - 2
packages/core/test/session-create.test.ts

@@ -543,11 +543,10 @@ describe("Session.create", () => {
         Effect.promise(() => tmpdir()),
         (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
       )
-      const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
       const targetLayer = AppNodeBuilder.build(
         LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
         [
-          [Database.node, targetDatabase],
+          [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
           [Bus.node, Bus.configured({ persist: true })],
         ],
       )

+ 10 - 4
packages/core/test/v1-migration.test.ts

@@ -18,9 +18,14 @@ import { tmpdir } from "./fixture/tmpdir"
 import path from "path"
 
 const makeDb = EffectDrizzleSqlite.makeWithDefaults()
-const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
+const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
   Effect.runPromise(
-    Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
+    Effect.scoped(
+      effect.pipe(
+        Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
+        Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
+      ),
+    ),
   )
 
 const session = (
@@ -772,7 +777,7 @@ describe("V1Migration database workflow", () => {
     `)
   })
 
-  const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
+  const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
     run(
       Effect.gen(function* () {
         const db = yield* makeDb
@@ -935,6 +940,7 @@ describe("V1Migration database workflow", () => {
     await database(
       Effect.gen(function* () {
         const { db } = yield* Database.Service
+        const global = yield* Global.Service
         yield* db.run(
           sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
         )
@@ -944,7 +950,7 @@ describe("V1Migration database workflow", () => {
           project_id: "global",
         })
         expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
-          worktree: path.parse(Global.Path.data).root,
+          worktree: path.parse(global.data).root,
         })
         expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
           value: '{"phase":"completed"}',

+ 1 - 0
packages/server/src/routes.ts

@@ -46,6 +46,7 @@ import type { ServerOptions } from "./options"
 import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
 
 const applicationServices = LayerNode.group([
+  Global.node,
   Database.node,
   Bus.node,
   EventLogger.node,

+ 5 - 2
packages/tui/src/app.tsx

@@ -72,7 +72,7 @@ import { DialogOpen } from "./component/dialog-open"
 import { SessionTabs } from "./component/session-tabs"
 import { sessionTabsFitVertically } from "./ui/layout"
 import { ThemeErrorToast } from "./component/theme-error-toast"
-import { ThemeProvider, useTheme, useThemes } from "./context/theme"
+import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
 import { Home } from "./routes/home"
 import { Session } from "./routes/session"
 import { PromptHistoryProvider } from "./prompt/history"
@@ -372,7 +372,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                                 <DataProvider>
                                                   <LocationProvider>
                                                     <SessionTabsProvider>
-                                                      <ThemeProvider mode={mode}>
+                                                      <ThemeProvider
+                                                        mode={mode}
+                                                        source={createThemeSource(global.config)}
+                                                      >
                                                         <ThemeErrorToast />
                                                         <LocalProvider>
                                                           <PromptStashProvider>

+ 5 - 6
packages/tui/src/context/theme.tsx

@@ -28,7 +28,6 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { useConfig } from "../config"
-import { Global } from "@opencode-ai/util/global"
 import { DevTools } from "../devtools"
 import { configDirectories } from "../util/config-directories"
 
@@ -69,15 +68,15 @@ export type ThemeSource = Readonly<{
   subscribeRefresh?(refresh: () => void): () => void
 }>
 
-const themeSource: ThemeSource = {
+export const createThemeSource = (config: string): ThemeSource => ({
   async discover() {
-    return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
+    return discoverThemes(configDirectories(config, process.cwd()))
   },
   subscribeRefresh(refresh) {
     process.on("SIGUSR2", refresh)
     return () => process.off("SIGUSR2", refresh)
   },
-}
+})
 
 export { discoverThemes } from "../theme/discovery"
 
@@ -139,11 +138,11 @@ subscribeThemes((themes) => setStore("themes", themes))
 
 const themeContext = createSimpleContext({
   name: "Theme",
-  init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
+  init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
     const renderer = useRenderer()
     const configState = useConfig()
     const config = configState.data
-    const themes = props.source ?? themeSource
+    const themes = props.source
     const pick = (value: unknown) => {
       if (value === "dark" || value === "light") return value
       return

+ 2 - 1
packages/tui/test/cli/tui/command-palette.test.tsx

@@ -10,6 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
 import { DialogProvider, useDialog } from "../../../src/ui/dialog"
 import { ToastProvider } from "../../../src/ui/toast"
 import { TestTuiContexts } from "../../fixture/tui-environment"
+import { emptyThemeSource } from "../../fixture/fixture"
 
 test("searches settings globally and opens the matching setting", async () => {
   let current: Info = {}
@@ -53,7 +54,7 @@ test("searches settings globally and opens the matching setting", async () => {
       <TestTuiContexts>
         <ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
           <Keymap.Provider>
-            <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+            <ThemeProvider mode="dark" source={emptyThemeSource}>
               <ToastProvider>
                 <DialogProvider>
                   <Fixture />

+ 2 - 1
packages/tui/test/cli/tui/data.test.tsx

@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
 import { Composer } from "../../../src/routes/session/composer"
 import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
 import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
+import { emptyThemeSource } from "../../fixture/fixture"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
@@ -1979,7 +1980,7 @@ test("keeps shell state scoped to location", async () => {
     return (
       <RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
         <Keymap.Provider>
-          <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+          <ThemeProvider mode="dark" source={emptyThemeSource}>
             <Composer sessionID="ses_shared" open={true} defaultTab="shell" />
           </ThemeProvider>
         </Keymap.Provider>

+ 2 - 1
packages/tui/test/cli/tui/dialog-open.test.tsx

@@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme"
 import { DialogProvider, useDialog } from "../../../src/ui/dialog"
 import { ToastProvider } from "../../../src/ui/toast"
 import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
+import { emptyThemeSource } from "../../fixture/fixture"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -226,7 +227,7 @@ async function renderOpen(
                       <DataProvider>
                         <LocationProvider>
                           <SessionTabsProvider>
-                            <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+                            <ThemeProvider mode="dark" source={emptyThemeSource}>
                               <DialogProvider>
                                 <Probe />
                               </DialogProvider>

+ 2 - 2
packages/tui/test/cli/tui/dialog-prompt.test.tsx

@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
 import { mkdir } from "node:fs/promises"
 import path from "node:path"
 import { onCleanup } from "solid-js"
-import { tmpdir } from "../../fixture/fixture"
+import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import type { TuiKeybind } from "../../../src/config/keybind"
 import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -58,7 +58,7 @@ async function mountPrompt(input: {
       >
         <ConfigProvider config={resolvedConfig}>
           <Keymap.Provider>
-            <ThemeProvider mode="dark">
+            <ThemeProvider mode="dark" source={emptyThemeSource}>
               <ToastProvider>
                 <DialogProvider>
                   <Prompt />

+ 3 - 3
packages/tui/test/cli/tui/dialog-select.test.tsx

@@ -9,7 +9,7 @@ import { dialogWidth } from "../../../src/ui/dialog"
 import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
 import { truncateFilePath } from "../../../src/ui/file-path"
 import { stringWidth } from "../../../src/util/string-width"
-import { tmpdir } from "../../fixture/fixture"
+import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
@@ -62,7 +62,7 @@ async function renderSelect(
       <TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
         <ConfigProvider config={config}>
           <Keymap.Provider>
-            <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+            <ThemeProvider mode="dark" source={emptyThemeSource}>
               <ToastProvider>
                 <DialogProvider>
                   <Select />
@@ -138,7 +138,7 @@ async function mountSelect(
       <TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
         <ConfigProvider config={config}>
           <Keymap.Provider>
-            <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+            <ThemeProvider mode="dark" source={emptyThemeSource}>
               <ToastProvider>
                 <DialogProvider>
                   <Fixture />

+ 2 - 1
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -4,6 +4,7 @@ import { testRender } from "@opentui/solid"
 import type { JSX } from "solid-js"
 import { onMount, type ParentProps } from "solid-js"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+import { emptyThemeSource } from "../../fixture/fixture"
 import { ThemeProvider, useThemes } from "../../../src/context/theme"
 import type { Plugin } from "@opencode-ai/plugin/tui"
 import { ConfigProvider } from "../../../src/config"
@@ -149,7 +150,7 @@ function withTheme(component: () => JSX.Element, onReady = () => {}) {
   return (
     <TestTuiContexts>
       <ConfigProvider config={createTuiResolvedConfig()}>
-        <ThemeProvider mode="dark">
+        <ThemeProvider mode="dark" source={emptyThemeSource}>
           <Ready onReady={onReady}>{component()}</Ready>
         </ThemeProvider>
       </ConfigProvider>

+ 2 - 1
packages/tui/test/cli/tui/diff-viewer.test.tsx

@@ -12,6 +12,7 @@ import type {
   Slot,
 } from "@opencode-ai/plugin/tui/context"
 import { ThemeProvider, useThemes } from "../../../src/context/theme"
+import { emptyThemeSource } from "../../fixture/fixture"
 import { ConfigProvider } from "../../../src/config"
 import { TuiKeybind } from "../../../src/config/keybind"
 import { Keymap } from "../../../src/context/keymap"
@@ -224,7 +225,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
         <ConfigProvider config={config}>
           <Keymap.Provider>
             <ToastProvider>
-              <ThemeProvider mode="dark">
+              <ThemeProvider mode="dark" source={emptyThemeSource}>
                 <DialogProvider>
                   <Content />
                 </DialogProvider>

+ 2 - 2
packages/tui/test/cli/tui/form.test.tsx

@@ -10,7 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
 import { Keymap } from "../../../src/context/keymap"
 import { ConfigProvider } from "../../../src/config"
 import { ToastProvider } from "../../../src/ui/toast"
-import { tmpdir } from "../../fixture/fixture"
+import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
@@ -72,7 +72,7 @@ async function mountForm(root: string, width = 80) {
           <ConfigProvider config={config}>
             <Keymap.Provider>
               <ClientProvider api={createApi(transport.fetch)}>
-                <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
+                <ThemeProvider mode="dark" source={emptyThemeSource}>
                   <ToastProvider>
                     <FormPrompt form={form} />
                   </ToastProvider>

+ 3 - 0
packages/tui/test/fixture/fixture.ts

@@ -1,6 +1,9 @@
 import { mkdtemp, realpath, rm } from "node:fs/promises"
 import path from "node:path"
 import os from "node:os"
+import type { ThemeSource } from "../../src/context/theme"
+
+export const emptyThemeSource: ThemeSource = { discover: () => Promise.resolve({}) }
 
 export async function tmpdir() {
   const directory = await realpath(await mkdtemp(path.join(os.tmpdir(), "opencode-tui-test-")))