From 7d9ac713a93aea30a1c32af24e4ec6fe936e0d79 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 21:42:33 -0400 Subject: [PATCH] test(config): migrate TUI config tests to Effect runner (#27206) --- packages/opencode/test/config/tui.test.ts | 1491 ++++++++++----------- 1 file changed, 744 insertions(+), 747 deletions(-) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5c4f091851..2be8c9fb99 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,114 +1,100 @@ -import { afterEach, beforeEach, expect, test } from "bun:test" +import { expect } from "bun:test" import path from "path" -import fs from "fs/promises" import { pathToFileURL } from "url" -import { provideTestInstance, tmpdir } from "../fixture/fixture" -import { InstanceRuntime } from "@/project/instance-runtime" -import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" -import { Config } from "@/config/config" -import { Global } from "@opencode-ai/core/global" -import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "../../src/effect/app-runtime" import { Effect, Layer } from "effect" -import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" import { ConfigPlugin } from "@/config/plugin" +import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" +import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const wintest = process.platform === "win32" ? test : test.skip -const clear = async (wait = false) => { - await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) - if (wait) await InstanceRuntime.disposeAllInstances() -} -const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const it = testEffect(Layer.mergeAll(Config.defaultLayer, AppFileSystem.defaultLayer)) +const winIt = process.platform === "win32" ? it.instance : it.instance.skip -beforeEach(async () => { - await clear(true) -}) +const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) => + path.join(Global.Path.config, file), +) -const getTuiConfig = async (directory: string) => - Effect.runPromise( - TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), - ), - ) - -async function withPlatform(platform: typeof process.platform, fn: () => Promise) { - const original = Object.getOwnPropertyDescriptor(process, "platform") - Object.defineProperty(process, "platform", { - ...original, - value: platform, - }) - try { - return await fn() - } finally { - if (original) Object.defineProperty(process, "platform", original) - } -} - -afterEach(async () => { +const cleanState = Effect.gen(function* () { + const fs = yield* AppFileSystem.Service delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG - await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {}) - await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {}) - await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) - await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) - await clear(true) + yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), { + discard: true, + }) }) -test("keeps server and tui plugin merge semantics aligned", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const local = path.join(dir, ".opencode") - await fs.mkdir(local, { recursive: true }) +const withCleanState = (self: Effect.Effect) => + Effect.acquireUseRelease( + cleanState, + () => self, + () => cleanState, + ) - await Bun.write( - path.join(Global.Path.config, "opencode.json"), - JSON.stringify( - { - plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], - }, - null, - 2, - ), - ) - await Bun.write( - path.join(Global.Path.config, "tui.json"), - JSON.stringify( - { - plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], - }, - null, - 2, - ), - ) +const withEnv = (name: string, value: string | undefined, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env[name] + else process.env[name] = previous + }), + ) - await Bun.write( - path.join(local, "opencode.json"), - JSON.stringify( - { - plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], - }, - null, - 2, - ), - ) - await Bun.write( - path.join(local, "tui.json"), - JSON.stringify( - { - plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], - }, - null, - 2, - ), - ) - }, - }) +const withPlatform = (platform: typeof process.platform, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const original = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { + ...original, + value: platform, + }) + return original + }), + () => self, + (original) => + Effect.sync(() => { + if (original) Object.defineProperty(process, "platform", original) + }), + ) - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const server = await load() - const tui = await getTuiConfig(tmp.path) +const getTuiConfig = (directory: string) => + TuiConfig.Service.use((svc) => svc.get()).pipe( + Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + ) + +it.instance("keeps server and tui plugin merge semantics aligned", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const local = path.join(test.directory, ".opencode") + yield* fs.makeDirectory(local, { recursive: true }) + + yield* fs.writeJson(path.join(Global.Path.config, "opencode.json"), { + plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], + }) + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { + plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], + }) + yield* fs.writeJson(path.join(local, "opencode.json"), { + plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], + }) + yield* fs.writeJson(path.join(local, "tui.json"), { + plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], + }) + + const server = yield* Config.Service.use((svc) => svc.get()) + const tui = yield* getTuiConfig(test.directory) const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) @@ -121,239 +107,228 @@ test("keeps server and tui plugin merge semantics aligned", async () => { expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins) expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins) expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope)) - }, - }) -}) + }), + ), +) -test("loads tui config with the same precedence order as server config paths", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2)) - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write( - path.join(dir, ".opencode", "tui.json"), +it.instance("loads tui config with the same precedence order as server config paths", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" }) + yield* fs.writeWithDirs( + path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2), ) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("local") - expect(config.diff_style).toBe("stacked") -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("local") + expect(config.diff_style).toBe("stacked") + }), + ), +) -test("resolves attention config defaults and overrides", async () => { - await using defaults = await tmpdir() - expect((await getTuiConfig(defaults.path)).attention).toEqual({ - enabled: false, - notifications: true, - sound: true, - volume: 0.4, - sound_pack: "opencode.default", - sounds: {}, - }) +it.instance("resolves attention config defaults and overrides", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance - await using overridden = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify( - { - attention: { - enabled: false, - notifications: false, - sound: false, - volume: 0.7, - sound_pack: "acme.soft", - sounds: { - default: path.join(dir, "default.mp3"), - question: pathToFileURL(path.join(dir, "question.mp3")).href, - error: "./error.mp3", - subagent_done: "./subagent-done.mp3", - }, - }, + expect((yield* getTuiConfig(test.directory)).attention).toEqual({ + enabled: false, + notifications: true, + sound: true, + volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, + }) + + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + attention: { + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + sound_pack: "acme.soft", + sounds: { + default: path.join(test.directory, "default.mp3"), + question: pathToFileURL(path.join(test.directory, "question.mp3")).href, + error: "./error.mp3", + subagent_done: "./subagent-done.mp3", }, - null, - 2, - ), - ) - }, - }) + }, + }) - expect((await getTuiConfig(overridden.path)).attention).toEqual({ - enabled: false, - notifications: false, - sound: false, - volume: 0.7, - sound_pack: "acme.soft", - sounds: { - default: path.join(overridden.path, "default.mp3"), - question: path.join(overridden.path, "question.mp3"), - error: path.join(overridden.path, "error.mp3"), - subagent_done: path.join(overridden.path, "subagent-done.mp3"), - }, - }) -}) + expect((yield* getTuiConfig(test.directory)).attention).toEqual({ + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + sound_pack: "acme.soft", + sounds: { + default: path.join(test.directory, "default.mp3"), + question: path.join(test.directory, "question.mp3"), + error: path.join(test.directory, "error.mp3"), + subagent_done: path.join(test.directory, "subagent-done.mp3"), + }, + }) + }), + ), +) -test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - theme: "migrated-theme", - tui: { scroll_speed: 5 }, - keybinds: { app_exit: "ctrl+q" }, - }, - null, - 2, - ), - ) - }, - }) +it.instance("migrates tui-specific keys from opencode.json when tui.json does not exist", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const source = path.join(test.directory, "opencode.json") + yield* fs.writeJson(source, { + theme: "migrated-theme", + tui: { scroll_speed: 5 }, + keybinds: { app_exit: "ctrl+q" }, + }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(5) - expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - expect(JSON.parse(text)).toMatchObject({ - theme: "migrated-theme", - scroll_speed: 5, - }) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.keybinds).toBeUndefined() - expect(server.tui).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(5) + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))).toMatchObject({ + theme: "migrated-theme", + scroll_speed: 5, + }) + const server = JSON.parse(yield* fs.readFileString(source)) + expect(server.theme).toBeUndefined() + expect(server.keybinds).toBeUndefined() + expect(server.tui).toBeUndefined() + expect(yield* fs.existsSafe(path.join(test.directory, "opencode.json.tui-migration.bak"))).toBe(true) + expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true) + }), + ), +) -test("migrates project legacy tui keys even when global tui.json already exists", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2)) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - theme: "project-migrated", - tui: { scroll_speed: 2 }, - }, - null, - 2, - ), - ) - }, - }) +it.instance("migrates project legacy tui keys even when global tui.json already exists", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" }) + yield* fs.writeJson(path.join(test.directory, "opencode.json"), { + theme: "project-migrated", + tui: { scroll_speed: 2 }, + }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("project-migrated") - expect(config.scroll_speed).toBe(2) - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("project-migrated") + expect(config.scroll_speed).toBe(2) + expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true) - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBeUndefined() - expect(server.tui).toBeUndefined() -}) + const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "opencode.json"))) + expect(server.theme).toBeUndefined() + expect(server.tui).toBeUndefined() + }), + ), +) -test("drops unknown legacy tui keys during migration", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - theme: "migrated-theme", - tui: { scroll_speed: 2, foo: 1 }, - }, - null, - 2, - ), - ) - }, - }) +it.instance("drops unknown legacy tui keys during migration", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "opencode.json"), { + theme: "migrated-theme", + tui: { scroll_speed: 2, foo: 1 }, + }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("migrated-theme") - expect(config.scroll_speed).toBe(2) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("migrated-theme") + expect(config.scroll_speed).toBe(2) - const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) - const migrated = JSON.parse(text) - expect(migrated.scroll_speed).toBe(2) - expect(migrated.foo).toBeUndefined() -}) + const migrated = JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json"))) + expect(migrated.scroll_speed).toBe(2) + expect(migrated.foo).toBeUndefined() + }), + ), +) -test("skips migration when opencode.jsonc is syntactically invalid", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.jsonc"), +it.instance("skips migration when opencode.jsonc is syntactically invalid", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeFileString( + path.join(test.directory, "opencode.jsonc"), `{ "theme": "broken-theme", "tui": { "scroll_speed": 2 } "username": "still-broken" }`, ) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBeUndefined() - expect(config.scroll_speed).toBeUndefined() - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) - expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false) - const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) - expect(source).toContain('"theme": "broken-theme"') - expect(source).toContain('"tui": { "scroll_speed": 2 }') -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBeUndefined() + expect(config.scroll_speed).toBeUndefined() + expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(false) + expect(yield* fs.existsSafe(path.join(test.directory, "opencode.jsonc.tui-migration.bak"))).toBe(false) + const source = yield* fs.readFileString(path.join(test.directory, "opencode.jsonc")) + expect(source).toContain('"theme": "broken-theme"') + expect(source).toContain('"tui": { "scroll_speed": 2 }') + }), + ), +) -test("skips migration when tui.json already exists", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2)) - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) - }, - }) +it.instance("skips migration when tui.json already exists", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "opencode.json"), { theme: "legacy" }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { diff_style: "stacked" }) - const config = await getTuiConfig(tmp.path) - expect(config.diff_style).toBe("stacked") - expect(config.theme).toBeUndefined() + const config = yield* getTuiConfig(test.directory) + expect(config.diff_style).toBe("stacked") + expect(config.theme).toBeUndefined() - const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json"))) - expect(server.theme).toBe("legacy") - expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false) -}) + const server = JSON.parse(yield* fs.readFileString(path.join(test.directory, "opencode.json"))) + expect(server.theme).toBe("legacy") + expect(yield* fs.existsSafe(path.join(test.directory, "opencode.json.tui-migration.bak"))).toBe(false) + }), + ), +) -test("continues loading tui config when legacy source cannot be stripped", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2)) - }, - }) +it.instance("continues loading tui config when legacy source cannot be stripped", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const source = path.join(test.directory, "opencode.json") + yield* fs.writeJson(source, { theme: "readonly-theme" }) - const source = path.join(tmp.path, "opencode.json") - await fs.chmod(source, 0o444) + yield* Effect.acquireUseRelease( + fs.chmod(source, 0o444), + () => + Effect.gen(function* () { + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("readonly-theme") + expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true) - try { - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("readonly-theme") - expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) + const server = JSON.parse(yield* fs.readFileString(source)) + expect(server.theme).toBe("readonly-theme") + }), + () => fs.chmod(source, 0o644).pipe(Effect.ignore), + ) + }), + ), +) - const server = JSON.parse(await Filesystem.readText(source)) - expect(server.theme).toBe("readonly-theme") - } finally { - await fs.chmod(source, 0o644) - } -}) - -test("migration backup preserves JSONC comments", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.jsonc"), +it.instance("migration backup preserves JSONC comments", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeFileString( + path.join(test.directory, "opencode.jsonc"), `{ // top-level comment "theme": "jsonc-theme", @@ -363,498 +338,520 @@ test("migration backup preserves JSONC comments", async () => { } }`, ) - }, - }) - await getTuiConfig(tmp.path) - 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") - expect(backup).toContain('"theme": "jsonc-theme"') - expect(backup).toContain('"scroll_speed": 1.5') -}) + yield* getTuiConfig(test.directory) + const backup = yield* fs.readFileString(path.join(test.directory, "opencode.jsonc.tui-migration.bak")) + expect(backup).toContain("// top-level comment") + expect(backup).toContain("// nested comment") + expect(backup).toContain('"theme": "jsonc-theme"') + expect(backup).toContain('"scroll_speed": 1.5') + }), + ), +) -test("migrates legacy tui keys across multiple opencode.json levels", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const nested = path.join(dir, "apps", "client") - await fs.mkdir(nested, { recursive: true }) - await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2)) - await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2)) - }, - }) - const config = await getTuiConfig(path.join(tmp.path, "apps", "client")) - 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) -}) +it.instance("migrates legacy tui keys across multiple opencode.json levels", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const nested = path.join(test.directory, "apps", "client") + yield* fs.makeDirectory(nested, { recursive: true }) + yield* fs.writeJson(path.join(test.directory, "opencode.json"), { theme: "root-theme" }) + yield* fs.writeJson(path.join(nested, "opencode.json"), { theme: "nested-theme" }) -test("flattens nested tui key inside tui.json", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - theme: "outer", - tui: { scroll_speed: 3, diff_style: "stacked" }, + const config = yield* getTuiConfig(nested) + expect(config.theme).toBe("nested-theme") + expect(yield* fs.existsSafe(path.join(test.directory, "tui.json"))).toBe(true) + expect(yield* fs.existsSafe(path.join(nested, "tui.json"))).toBe(true) + }), + ), +) + +it.instance("flattens nested tui key inside tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + theme: "outer", + tui: { scroll_speed: 3, diff_style: "stacked" }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.scroll_speed).toBe(3) + expect(config.diff_style).toBe("stacked") + expect(config.theme).toBe("outer") + }), + ), +) + +it.instance("top-level keys in tui.json take precedence over nested tui key", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + diff_style: "auto", + tui: { diff_style: "stacked", scroll_speed: 2 }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.diff_style).toBe("auto") + expect(config.scroll_speed).toBe(2) + }), + ), +) + +it.instance("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const custom = path.join(test.directory, "custom-tui.json") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project", diff_style: "auto" }) + yield* fs.writeJson(custom, { theme: "custom", diff_style: "stacked" }) + + yield* withEnv( + "OPENCODE_TUI_CONFIG", + custom, + Effect.gen(function* () { + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("project") + expect(config.diff_style).toBe("auto") }), ) - }, - }) + }), + ), +) - const config = await getTuiConfig(tmp.path) - expect(config.scroll_speed).toBe(3) - expect(config.diff_style).toBe("stacked") - // top-level keys take precedence over nested tui keys - expect(config.theme).toBe("outer") -}) +it.instance("merges keybind overrides across precedence layers", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { keybinds: { app_exit: "ctrl+q" } }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { theme_list: "ctrl+k" } }) -test("top-level keys in tui.json take precedence over nested tui key", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - diff_style: "auto", - tui: { diff_style: "stacked", scroll_speed: 2 }, - }), + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k") + }), + ), +) + +it.instance("resolves keybind lookup from canonical keybinds", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { + leader: { key: { name: "g", ctrl: true } }, + command_list: "alt+p", + which_key_toggle: "alt+k", + editor_open: "ctrl+e", + "prompt.autocomplete.next": "ctrl+j", + "dialog.mcp.toggle": "ctrl+t", + model_favorite_toggle: "ctrl+f", + "dialog.plugins.install": "shift+i", + }, + leader_timeout: 1234, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) + expect(config.leader_timeout).toBe(1234) + expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") + expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") + expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") + expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p") + expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]") + expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe( + "Toggle which-key panel", ) - }, - }) + expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e") + expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j") + expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t") + expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f") + expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i") + expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([ + "dialog.plugins.install", + ]) + }), + ), +) - const config = await getTuiConfig(tmp.path) - expect(config.diff_style).toBe("auto") - expect(config.scroll_speed).toBe(2) -}) +it.instance("keybinds accept OpenTUI binding specs", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + keybinds: { + command_list: [{ key: "alt+p", preventDefault: false }], + editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" }, + "prompt.autocomplete.next": false, + plugin_manager: "ctrl+shift+p", + }, + }) -test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" })) - const custom = path.join(dir, "custom-tui.json") - await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" })) - process.env.OPENCODE_TUI_CONFIG = custom - }, - }) + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("command.palette.show")).toEqual([ + { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" }, + ]) + expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({ + key: { name: "e", ctrl: true }, + cmd: "prompt.editor", + group: "Explicit", + }) + expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([]) + expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p") + }), + ), +) - const config = await getTuiConfig(tmp.path) - // 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 - expect(config.diff_style).toBe("auto") -}) +winIt("defaults Ctrl+Z to input undo on Windows", () => + withCleanState( + Effect.gen(function* () { + const test = yield* TestInstance + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") + }), + ), +) -test("merges keybind overrides across precedence layers", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } })) - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } })) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") - expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k") -}) +winIt("keeps explicit input undo overrides on Windows", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } }) -test("resolves keybind lookup from canonical keybinds", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") + }), + ), +) + +winIt("ignores terminal suspend bindings on Windows", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { terminal_suspend: "alt+z" } }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") + }), + ), +) + +it.instance("applies Windows keybind defaults", () => + withCleanState( + withPlatform( + "win32", + Effect.gen(function* () { + const test = yield* TestInstance + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") + }), + ), + ), +) + +it.instance("ignores explicit keybind terminal suspend binding on Windows", () => + withCleanState( + withPlatform( + "win32", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { - leader: { key: { name: "g", ctrl: true } }, - command_list: "alt+p", - which_key_toggle: "alt+k", - editor_open: "ctrl+e", - "prompt.autocomplete.next": "ctrl+j", - "dialog.mcp.toggle": "ctrl+t", - model_favorite_toggle: "ctrl+f", - "dialog.plugins.install": "shift+i", + terminal_suspend: "alt+z", }, - leader_timeout: 1234, - }), - ) - }, - }) + }) - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) - expect(config.leader_timeout).toBe(1234) - expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") - expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") - expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") - expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") - expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p") - expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]") - expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe( - "Toggle which-key panel", - ) - expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e") - expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j") - expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t") - expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f") - expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i") - expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([ - "dialog.plugins.install", - ]) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + }), + ), + ), +) -test("keybinds accept OpenTUI binding specs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ +it.instance("keeps explicit configured keybind input undo on Windows", () => + withCleanState( + withPlatform( + "win32", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { - command_list: [{ key: "alt+p", preventDefault: false }], - editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" }, - "prompt.autocomplete.next": false, - plugin_manager: "ctrl+shift+p", + input_undo: "ctrl+y", }, + }) + + const config = yield* getTuiConfig(test.directory) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") + }), + ), + ), +) + +it.instance("OPENCODE_TUI_CONFIG provides settings when no project config exists", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const custom = path.join(test.directory, "custom-tui.json") + yield* fs.writeJson(custom, { theme: "from-env", diff_style: "stacked" }) + + yield* withEnv( + "OPENCODE_TUI_CONFIG", + custom, + Effect.gen(function* () { + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("from-env") + expect(config.diff_style).toBe("stacked") }), ) - }, - }) + }), + ), +) - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("command.palette.show")).toEqual([ - { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" }, - ]) - expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({ - key: { name: "e", ctrl: true }, - cmd: "prompt.editor", - group: "Explicit", - }) - expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([]) - expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p") -}) +it.instance("does not derive tui path from OPENCODE_CONFIG", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + const customDir = path.join(test.directory, "custom") + yield* fs.makeDirectory(customDir, { recursive: true }) + yield* fs.writeJson(path.join(customDir, "opencode.json"), { model: "test/model" }) + yield* fs.writeJson(path.join(customDir, "tui.json"), { theme: "should-not-load" }) -wintest("defaults Ctrl+Z to input undo on Windows", async () => { - await using tmp = await tmpdir() - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("terminal.suspend")).toEqual([]) - expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") -}) + yield* withEnv( + "OPENCODE_CONFIG", + path.join(customDir, "opencode.json"), + Effect.gen(function* () { + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBeUndefined() + }), + ) + }), + ), +) -wintest("keeps explicit input undo overrides on Windows", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } })) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("terminal.suspend")).toEqual([]) - expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") -}) +it.instance("applies env and file substitutions in tui.json", () => + withCleanState( + withEnv( + "TUI_THEME_TEST", + "env-theme", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeFileString(path.join(test.directory, "keybind.txt"), "ctrl+q") + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + theme: "{env:TUI_THEME_TEST}", + keybinds: { app_exit: "{file:keybind.txt}" }, + }) -wintest("ignores terminal suspend bindings on Windows", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } })) - }, - }) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("env-theme") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + }), + ), + ), +) - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("terminal.suspend")).toEqual([]) - expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") -}) - -test("applies Windows keybind defaults", async () => { - await withPlatform("win32", async () => { - await using tmp = await tmpdir() - - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("terminal.suspend")).toEqual([]) - expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") - }) -}) - -test("ignores explicit keybind terminal suspend binding on Windows", async () => { - await withPlatform("win32", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - keybinds: { - terminal_suspend: "alt+z", - }, - }), - ) - }, - }) - - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("terminal.suspend")).toEqual([]) - }) -}) - -test("keeps explicit configured keybind input undo on Windows", async () => { - await withPlatform("win32", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - keybinds: { - input_undo: "ctrl+y", - }, - }), - ) - }, - }) - - const config = await getTuiConfig(tmp.path) - expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") - }) -}) - -test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const custom = path.join(dir, "custom-tui.json") - await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" })) - process.env.OPENCODE_TUI_CONFIG = custom - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("from-env") - expect(config.diff_style).toBe("stacked") -}) - -test("does not derive tui path from OPENCODE_CONFIG", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const customDir = path.join(dir, "custom") - await fs.mkdir(customDir, { recursive: true }) - await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" })) - await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" })) - process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json") - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBeUndefined() -}) - -test("applies env and file substitutions in tui.json", async () => { - const original = process.env.TUI_THEME_TEST - process.env.TUI_THEME_TEST = "env-theme" - try { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q") - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - theme: "{env:TUI_THEME_TEST}", - keybinds: { app_exit: "{file:keybind.txt}" }, - }), - ) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("env-theme") - expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") - } finally { - if (original === undefined) delete process.env.TUI_THEME_TEST - else process.env.TUI_THEME_TEST = original - } -}) - -test("applies file substitutions when first identical token is in a commented line", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "theme.txt"), "resolved-theme") - await Bun.write( - path.join(dir, "tui.jsonc"), +it.instance("applies file substitutions when first identical token is in a commented line", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeFileString(path.join(test.directory, "theme.txt"), "resolved-theme") + yield* fs.writeFileString( + path.join(test.directory, "tui.jsonc"), `{ // "theme": "{file:theme.txt}", "theme": "{file:theme.txt}" }`, ) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.theme).toBe("resolved-theme") -}) -test("loads .opencode/tui.json", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2)) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.diff_style).toBe("stacked") -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("resolved-theme") + }), + ), +) -test("supports tuple plugin specs with options in tui.json", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]], - }), +it.instance("loads .opencode/tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeWithDirs( + path.join(test.directory, ".opencode", "tui.json"), + JSON.stringify({ diff_style: "stacked" }, null, 2), ) - }, - }) - const config = await getTuiConfig(tmp.path) - expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.diff_style).toBe("stacked") + }), + ), +) -test("deduplicates tuple plugin specs by name with higher precedence winning", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(Global.Path.config, "tui.json"), - JSON.stringify({ - plugin: [["acme-plugin@1.0.0", { source: "global" }]], - }), - ) - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - plugin: [ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ], - }), - ) - }, - }) +it.instance("supports tuple plugin specs with options in tui.json", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]], + }) - const config = await getTuiConfig(tmp.path) - expect(config.plugin).toEqual([ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ]) - expect(config.plugin_origins).toEqual([ - { - spec: ["acme-plugin@2.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - { - spec: ["second-plugin@3.0.0", { source: "project" }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], + scope: "local", + source: path.join(test.directory, "tui.json"), + }, + ]) + }), + ), +) -test("tracks global and local plugin metadata in merged tui config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(Global.Path.config, "tui.json"), - JSON.stringify({ - plugin: ["global-plugin@1.0.0"], - }), - ) - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - plugin: ["local-plugin@2.0.0"], - }), - ) - }, - }) +it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { + plugin: [["acme-plugin@1.0.0", { source: "global" }]], + }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + plugin: [ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ], + }) - const config = await getTuiConfig(tmp.path) - expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) - expect(config.plugin_origins).toEqual([ - { - spec: "global-plugin@1.0.0", - scope: "global", - source: path.join(Global.Path.config, "tui.json"), - }, - { - spec: "local-plugin@2.0.0", - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ]) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.plugin).toEqual([ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ]) + expect(config.plugin_origins).toEqual([ + { + spec: ["acme-plugin@2.0.0", { source: "project" }], + scope: "local", + source: path.join(test.directory, "tui.json"), + }, + { + spec: ["second-plugin@3.0.0", { source: "project" }], + scope: "local", + source: path.join(test.directory, "tui.json"), + }, + ]) + }), + ), +) -test("merges plugin_enabled flags across config layers", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(Global.Path.config, "tui.json"), - JSON.stringify({ - plugin_enabled: { - "internal:sidebar-context": false, - "demo.plugin": true, - }, - }), - ) - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - plugin_enabled: { - "demo.plugin": false, - "local.plugin": true, - }, - }), - ) - }, - }) +it.instance("tracks global and local plugin metadata in merged tui config", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] }) - const config = await getTuiConfig(tmp.path) - expect(config.plugin_enabled).toEqual({ - "internal:sidebar-context": false, - "demo.plugin": false, - "local.plugin": true, - }) -}) + const config = yield* getTuiConfig(test.directory) + expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) + expect(config.plugin_origins).toEqual([ + { + spec: "global-plugin@1.0.0", + scope: "global", + source: path.join(Global.Path.config, "tui.json"), + }, + { + spec: "local-plugin@2.0.0", + scope: "local", + source: path.join(test.directory, "tui.json"), + }, + ]) + }), + ), +) -test("silently skips malformed tui.json — load failures degrade to {}", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",') - await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) - }, - }) +it.instance("merges plugin_enabled flags across config layers", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { + plugin_enabled: { + "internal:sidebar-context": false, + "demo.plugin": true, + }, + }) + yield* fs.writeJson(path.join(test.directory, "tui.json"), { + plugin_enabled: { + "demo.plugin": false, + "local.plugin": true, + }, + }) - const config = await getTuiConfig(tmp.path) - // Project tui.json is malformed → silently skipped (logs a warning) - // .opencode/tui.json (lower precedence in this path) still loads - expect(config.theme).toBe("fallback") -}) + const config = yield* getTuiConfig(test.directory) + expect(config.plugin_enabled).toEqual({ + "internal:sidebar-context": false, + "demo.plugin": false, + "local.plugin": true, + }) + }), + ), +) -test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - // tui.json exists as a DIRECTORY rather than a file → readFileString fails - // with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes - // that through catchCause → log + skip, so a fallback layer should still load. - await fs.mkdir(path.join(dir, "tui.json"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) - }, - }) +it.instance("silently skips malformed tui.json - load failures degrade to {}", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",') + yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) - const config = await getTuiConfig(tmp.path) - // Did NOT crash; .opencode/tui.json (lower precedence) still loads. - expect(config.theme).toBe("fallback") -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("fallback") + }), + ), +) -test("missing tui.json — silently treated as empty (ENOENT path)", async () => { - await using tmp = await tmpdir({}) +it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directory) - fallback layer still loads", () => + withCleanState( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const test = yield* TestInstance + yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true }) + yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) - // No tui.json anywhere. Should not throw. - const config = await getTuiConfig(tmp.path) - expect(config).toBeDefined() - // No theme set anywhere. - expect(config.theme).toBeUndefined() -}) + const config = yield* getTuiConfig(test.directory) + expect(config.theme).toBe("fallback") + }), + ), +) + +it.instance("missing tui.json - silently treated as empty (ENOENT path)", () => + withCleanState( + Effect.gen(function* () { + const test = yield* TestInstance + const config = yield* getTuiConfig(test.directory) + expect(config).toBeDefined() + expect(config.theme).toBeUndefined() + }), + ), +)