mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
861 lines
28 KiB
TypeScript
861 lines
28 KiB
TypeScript
import { afterEach, beforeEach, expect, test } 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 { ConfigPlugin } from "@/config/plugin"
|
|
|
|
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()))
|
|
|
|
beforeEach(async () => {
|
|
await clear(true)
|
|
})
|
|
|
|
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<Value>(platform: typeof process.platform, fn: () => Promise<Value>) {
|
|
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 () => {
|
|
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)
|
|
})
|
|
|
|
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 })
|
|
|
|
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,
|
|
),
|
|
)
|
|
|
|
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,
|
|
),
|
|
)
|
|
},
|
|
})
|
|
|
|
await provideTestInstance({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const server = await load()
|
|
const tui = await getTuiConfig(tmp.path)
|
|
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
|
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
|
|
|
expect(serverPlugins).toEqual(tuiPlugins)
|
|
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
|
expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
|
|
|
|
const serverOrigins = server.plugin_origins ?? []
|
|
const tuiOrigins = tui.plugin_origins ?? []
|
|
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"),
|
|
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")
|
|
})
|
|
|
|
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: {},
|
|
})
|
|
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
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"),
|
|
},
|
|
})
|
|
})
|
|
|
|
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,
|
|
),
|
|
)
|
|
},
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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,
|
|
),
|
|
)
|
|
},
|
|
})
|
|
|
|
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 server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "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,
|
|
),
|
|
)
|
|
},
|
|
})
|
|
|
|
const config = await getTuiConfig(tmp.path)
|
|
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()
|
|
})
|
|
|
|
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"),
|
|
`{
|
|
"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 }')
|
|
})
|
|
|
|
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))
|
|
},
|
|
})
|
|
|
|
const config = await getTuiConfig(tmp.path)
|
|
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)
|
|
})
|
|
|
|
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))
|
|
},
|
|
})
|
|
|
|
const source = path.join(tmp.path, "opencode.json")
|
|
await fs.chmod(source, 0o444)
|
|
|
|
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(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"),
|
|
`{
|
|
// top-level comment
|
|
"theme": "jsonc-theme",
|
|
"tui": {
|
|
// nested comment
|
|
"scroll_speed": 1.5
|
|
}
|
|
}`,
|
|
)
|
|
},
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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 = 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")
|
|
})
|
|
|
|
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 = await getTuiConfig(tmp.path)
|
|
expect(config.diff_style).toBe("auto")
|
|
expect(config.scroll_speed).toBe(2)
|
|
})
|
|
|
|
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 = 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")
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
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({
|
|
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 = 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("<leader>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",
|
|
])
|
|
})
|
|
|
|
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({
|
|
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",
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
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 = 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"),
|
|
`{
|
|
// "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")
|
|
})
|
|
|
|
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" }]],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
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"),
|
|
},
|
|
])
|
|
})
|
|
|
|
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" }],
|
|
],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
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"),
|
|
},
|
|
])
|
|
})
|
|
|
|
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"],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
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"),
|
|
},
|
|
])
|
|
})
|
|
|
|
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,
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
const config = await getTuiConfig(tmp.path)
|
|
expect(config.plugin_enabled).toEqual({
|
|
"internal:sidebar-context": false,
|
|
"demo.plugin": false,
|
|
"local.plugin": true,
|
|
})
|
|
})
|
|
|
|
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" }))
|
|
},
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
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" }))
|
|
},
|
|
})
|
|
|
|
const config = await getTuiConfig(tmp.path)
|
|
// Did NOT crash; .opencode/tui.json (lower precedence) still loads.
|
|
expect(config.theme).toBe("fallback")
|
|
})
|
|
|
|
test("missing tui.json — silently treated as empty (ENOENT path)", async () => {
|
|
await using tmp = await tmpdir({})
|
|
|
|
// No tui.json anywhere. Should not throw.
|
|
const config = await getTuiConfig(tmp.path)
|
|
expect(config).toBeDefined()
|
|
// No theme set anywhere.
|
|
expect(config.theme).toBeUndefined()
|
|
})
|