fix: logo 右半部分从 CODING 改为 CODE
去掉难以正确渲染的 N 和 G 字母,右半部分简化为 CODE(4 字母), 与左半部分 AIR 组合为 AIR CODE。
This commit is contained in:
47
packages/opencode/test/config/agent-color.test.ts
Normal file
47
packages/opencode/test/config/agent-color.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Config } from "@/config/config"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
it.instance(
|
||||
"agent color parsed from project config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.use.get()
|
||||
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
||||
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Agent.get includes color from config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* AgentSvc.use.get("plan")
|
||||
expect(plan?.color).toBe("#A855F7")
|
||||
const build = yield* AgentSvc.use.get("build")
|
||||
expect(build?.color).toBe("accent")
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
2041
packages/opencode/test/config/config.test.ts
Normal file
2041
packages/opencode/test/config/config.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
packages/opencode/test/config/entry-name.test.ts
Normal file
57
packages/opencode/test/config/entry-name.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { posix } from "path"
|
||||
import { configEntryNameFromPath } from "@/config/entry-name"
|
||||
|
||||
// Use POSIX semantics so the test is deterministic regardless of host OS —
|
||||
// production code passes paths through `path.relative` on the runtime
|
||||
// platform, but the helper normalizes via `replaceAll("\\", "/")`, so the
|
||||
// regression assertion ("the helper returns the bare name") holds on either
|
||||
// platform as long as we feed it a relative path. Using `posix.relative`
|
||||
// keeps the intermediate values stable across CI runners.
|
||||
|
||||
// The prefixes shipped by config/agent.ts after the relative-path refactor.
|
||||
const AGENT_PREFIXES = ["agent/", "agents/"]
|
||||
|
||||
describe("configEntryNameFromPath", () => {
|
||||
test("strips an `agents/` prefix and returns the bare name", () => {
|
||||
expect(configEntryNameFromPath("agents/build.md", AGENT_PREFIXES)).toBe("build")
|
||||
})
|
||||
|
||||
test("strips an `agent/` (singular) prefix", () => {
|
||||
expect(configEntryNameFromPath("agent/build.md", AGENT_PREFIXES)).toBe("build")
|
||||
})
|
||||
|
||||
test("preserves nested subdirectories in the key", () => {
|
||||
expect(configEntryNameFromPath("agents/team/build.md", AGENT_PREFIXES)).toBe("team/build")
|
||||
})
|
||||
|
||||
test("normalizes Windows-style backslashes", () => {
|
||||
expect(configEntryNameFromPath("agents\\team\\build.md", AGENT_PREFIXES)).toBe("team/build")
|
||||
})
|
||||
|
||||
test("falls back to basename when no prefix matches", () => {
|
||||
expect(configEntryNameFromPath("orphaned.md", AGENT_PREFIXES)).toBe("orphaned")
|
||||
expect(configEntryNameFromPath("anywhere/orphaned.md", [])).toBe("orphaned")
|
||||
})
|
||||
|
||||
// Regression for #25713: a username (or any parent segment) containing
|
||||
// `agent` or `agents` used to win the substring match before the real
|
||||
// `agents/` directory could match, leaking the entire intervening path into
|
||||
// the agent key (e.g. `.config/opencode/agents/build`). Anchoring at the
|
||||
// caller via `path.relative(dir, item)` makes this impossible — the relative
|
||||
// path is always rooted at `agent/` or `agents/`.
|
||||
test("regression #25713: caller passes relative path; parent /agent/ segment is irrelevant", () => {
|
||||
const dir = "/home/agent/.config/opencode"
|
||||
const item = "/home/agent/.config/opencode/agents/build.md"
|
||||
const relative = posix.relative(dir, item)
|
||||
expect(relative).toBe("agents/build.md")
|
||||
expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
|
||||
})
|
||||
|
||||
test("regression #25713: parent /agents/ segment is irrelevant", () => {
|
||||
const dir = "/srv/agents/team/.config/opencode"
|
||||
const item = "/srv/agents/team/.config/opencode/agents/build.md"
|
||||
const relative = posix.relative(dir, item)
|
||||
expect(configEntryNameFromPath(relative, AGENT_PREFIXES)).toBe("build")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
---
|
||||
|
||||
Content
|
||||
28
packages/opencode/test/config/fixtures/frontmatter.md
Normal file
28
packages/opencode/test/config/fixtures/frontmatter.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: "This is a description wrapped in quotes"
|
||||
# field: this is a commented out field that should be ignored
|
||||
occupation: This man has the following occupation: Software Engineer
|
||||
title: 'Hello World'
|
||||
name: John "Doe"
|
||||
|
||||
family: He has no 'family'
|
||||
summary: >
|
||||
This is a summary
|
||||
url: https://example.com:8080/path?query=value
|
||||
time: The time is 12:30:00 PM
|
||||
nested: First: Second: Third: Fourth
|
||||
quoted_colon: "Already quoted: no change needed"
|
||||
single_quoted_colon: 'Single quoted: also fine'
|
||||
mixed: He said "hello: world" and then left
|
||||
empty:
|
||||
dollar: Use $' and $& for special patterns
|
||||
---
|
||||
|
||||
Content that should not be parsed:
|
||||
|
||||
fake_field: this is not yaml
|
||||
another: neither is this
|
||||
time: 10:30:00 AM
|
||||
url: https://should-not-be-parsed.com:3000
|
||||
|
||||
The above lines look like YAML but are just content.
|
||||
11
packages/opencode/test/config/fixtures/markdown-header.md
Normal file
11
packages/opencode/test/config/fixtures/markdown-header.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Response Formatting Requirements
|
||||
|
||||
Always structure your responses using clear markdown formatting:
|
||||
|
||||
- By default don't put information into tables for questions (but do put information into tables when creating or updating files)
|
||||
- Use headings (##, ###) to organise sections, always
|
||||
- Use bullet points or numbered lists for multiple items
|
||||
- Use code blocks with language tags for any code
|
||||
- Use **bold** for key terms and emphasis
|
||||
- Use tables when comparing options or listing structured data
|
||||
- Break long responses into logical sections with headings
|
||||
1
packages/opencode/test/config/fixtures/no-frontmatter.md
Normal file
1
packages/opencode/test/config/fixtures/no-frontmatter.md
Normal file
@@ -0,0 +1 @@
|
||||
Content
|
||||
13
packages/opencode/test/config/fixtures/weird-model-id.md
Normal file
13
packages/opencode/test/config/fixtures/weird-model-id.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: General coding and planning agent
|
||||
mode: subagent
|
||||
model: synthetic/hf:zai-org/GLM-4.7
|
||||
tools:
|
||||
write: true
|
||||
read: true
|
||||
edit: true
|
||||
stuff: >
|
||||
This is some stuff
|
||||
---
|
||||
|
||||
Strictly follow da rules
|
||||
69
packages/opencode/test/config/lsp.test.ts
Normal file
69
packages/opencode/test/config/lsp.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigLSPV1 } from "@opencode-ai/core/v1/config/lsp"
|
||||
|
||||
// The LSP config refinement enforces: any custom (non-builtin) LSP server
|
||||
// entry must declare an `extensions` array so the client knows which files
|
||||
// the server should attach to. Builtin server IDs and explicitly disabled
|
||||
// entries are exempt.
|
||||
//
|
||||
// `typescript` is a builtin server id (see src/lsp/server.ts).
|
||||
describe("ConfigLSPV1.Info refinement", () => {
|
||||
const decodeEffect = Schema.decodeUnknownSync(ConfigLSPV1.Info)
|
||||
|
||||
describe("accepted inputs", () => {
|
||||
test("true and false pass (top-level toggle)", () => {
|
||||
expect(decodeEffect(true)).toBe(true)
|
||||
expect(decodeEffect(false)).toBe(false)
|
||||
})
|
||||
|
||||
test("builtin server with no extensions passes", () => {
|
||||
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("custom server WITH extensions passes", () => {
|
||||
const input = {
|
||||
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
|
||||
}
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("disabled custom server passes (no extensions needed)", () => {
|
||||
const input = { "my-lsp": { disabled: true as const } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("mix of builtin and custom with extensions passes", () => {
|
||||
const input = {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
|
||||
}
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("rejected inputs", () => {
|
||||
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
|
||||
|
||||
test("custom server WITHOUT extensions fails via Effect decode", () => {
|
||||
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
|
||||
})
|
||||
|
||||
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
|
||||
// Boolean(['']) is true, so a non-empty array of strings is fine.
|
||||
// Boolean([]) is also true in JS, so empty arrays are accepted by the
|
||||
// refinement. This test documents current behavior.
|
||||
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
|
||||
expect(decodeEffect(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("custom server without extensions mixed with a valid builtin still fails", () => {
|
||||
const input = {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
"my-lsp": { command: ["my-lsp-bin"] },
|
||||
}
|
||||
expect(() => decodeEffect(input)).toThrow(expectedMessage)
|
||||
})
|
||||
})
|
||||
})
|
||||
228
packages/opencode/test/config/markdown.test.ts
Normal file
228
packages/opencode/test/config/markdown.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { expect, test, describe } from "bun:test"
|
||||
import { ConfigMarkdown } from "@/config/markdown"
|
||||
|
||||
describe("ConfigMarkdown: normal template", () => {
|
||||
const template = `This is a @valid/path/to/a/file and it should also match at
|
||||
the beginning of a line:
|
||||
|
||||
@another-valid/path/to/a/file
|
||||
|
||||
but this is not:
|
||||
|
||||
- Adds a "Co-authored-by:" footer which clarifies which AI agent
|
||||
helped create this commit, using an appropriate \`noreply@...\`
|
||||
or \`noreply@anthropic.com\` email address.
|
||||
|
||||
We also need to deal with files followed by @commas, ones
|
||||
with @file-extensions.md, even @multiple.extensions.bak,
|
||||
hidden directories like @.config/ or files like @.bashrc
|
||||
and ones at the end of a sentence like @foo.md.
|
||||
|
||||
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
|
||||
as well as @~/home-files and @~/paths/under/home.txt.
|
||||
|
||||
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
|
||||
|
||||
const matches = ConfigMarkdown.files(template)
|
||||
|
||||
test("should extract exactly 12 file references", () => {
|
||||
expect(matches.length).toBe(12)
|
||||
})
|
||||
|
||||
test("should extract valid/path/to/a/file", () => {
|
||||
expect(matches[0][1]).toBe("valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract another-valid/path/to/a/file", () => {
|
||||
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
|
||||
})
|
||||
|
||||
test("should extract paths ignoring comma after", () => {
|
||||
expect(matches[2][1]).toBe("commas")
|
||||
})
|
||||
|
||||
test("should extract a path with a file extension and comma after", () => {
|
||||
expect(matches[3][1]).toBe("file-extensions.md")
|
||||
})
|
||||
|
||||
test("should extract a path with multiple dots and comma after", () => {
|
||||
expect(matches[4][1]).toBe("multiple.extensions.bak")
|
||||
})
|
||||
|
||||
test("should extract hidden directory", () => {
|
||||
expect(matches[5][1]).toBe(".config/")
|
||||
})
|
||||
|
||||
test("should extract hidden file", () => {
|
||||
expect(matches[6][1]).toBe(".bashrc")
|
||||
})
|
||||
|
||||
test("should extract a file ignoring period at end of sentence", () => {
|
||||
expect(matches[7][1]).toBe("foo.md")
|
||||
})
|
||||
|
||||
test("should extract an absolute path with an extension", () => {
|
||||
expect(matches[8][1]).toBe("/absolute/paths.txt")
|
||||
})
|
||||
|
||||
test("should extract an absolute path without an extension", () => {
|
||||
expect(matches[9][1]).toBe("/without/extensions")
|
||||
})
|
||||
|
||||
test("should extract an absolute path in home directory", () => {
|
||||
expect(matches[10][1]).toBe("~/home-files")
|
||||
})
|
||||
|
||||
test("should extract an absolute path under home directory", () => {
|
||||
expect(matches[11][1]).toBe("~/paths/under/home.txt")
|
||||
})
|
||||
|
||||
test("should not match when preceded by backtick", () => {
|
||||
const backtickTest = "This `@should/not/match` should be ignored"
|
||||
const backtickMatches = ConfigMarkdown.files(backtickTest)
|
||||
expect(backtickMatches.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should not match email addresses", () => {
|
||||
const emailTest = "Contact user@example.com for help"
|
||||
const emailMatches = ConfigMarkdown.files(emailTest)
|
||||
expect(emailMatches.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing", async () => {
|
||||
const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(parsed).toBeDefined()
|
||||
expect(parsed.data).toBeDefined()
|
||||
expect(parsed.content).toBeDefined()
|
||||
})
|
||||
|
||||
test("should extract description field", () => {
|
||||
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
|
||||
})
|
||||
|
||||
test("should extract occupation field with colon in value", () => {
|
||||
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer")
|
||||
})
|
||||
|
||||
test("should extract title field with single quotes", () => {
|
||||
expect(parsed.data.title).toBe("Hello World")
|
||||
})
|
||||
|
||||
test("should extract name field with embedded quotes", () => {
|
||||
expect(parsed.data.name).toBe('John "Doe"')
|
||||
})
|
||||
|
||||
test("should extract family field with embedded single quotes", () => {
|
||||
expect(parsed.data.family).toBe("He has no 'family'")
|
||||
})
|
||||
|
||||
test("should extract multiline summary field", () => {
|
||||
expect(parsed.data.summary).toBe("This is a summary\n")
|
||||
})
|
||||
|
||||
test("should not include commented fields in data", () => {
|
||||
expect(parsed.data.field).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should extract URL with port", () => {
|
||||
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value")
|
||||
})
|
||||
|
||||
test("should extract time with colons", () => {
|
||||
expect(parsed.data.time).toBe("The time is 12:30:00 PM")
|
||||
})
|
||||
|
||||
test("should extract value with multiple colons", () => {
|
||||
expect(parsed.data.nested).toBe("First: Second: Third: Fourth")
|
||||
})
|
||||
|
||||
test("should preserve already double-quoted values with colons", () => {
|
||||
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
|
||||
})
|
||||
|
||||
test("should preserve already single-quoted values with colons", () => {
|
||||
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
|
||||
})
|
||||
|
||||
test("should extract value with quotes and colons mixed", () => {
|
||||
expect(parsed.data.mixed).toBe('He said "hello: world" and then left')
|
||||
})
|
||||
|
||||
test("should handle empty values", () => {
|
||||
expect(parsed.data.empty).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle dollar sign replacement patterns literally", () => {
|
||||
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
|
||||
})
|
||||
|
||||
test("should not parse fake yaml from content", () => {
|
||||
expect(parsed.data.fake_field).toBeUndefined()
|
||||
expect(parsed.data.another).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should extract content after frontmatter without modification", () => {
|
||||
expect(parsed.content).toContain("Content that should not be parsed:")
|
||||
expect(parsed.content).toContain("fake_field: this is not yaml")
|
||||
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim()).toBe("Content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
|
||||
|
||||
test("should parse without throwing", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim()).toBe("Content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter parsing w/ Markdown header", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/markdown-header.md")
|
||||
|
||||
test("should parse and match", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.content.trim().replace(/\r\n/g, "\n")).toBe(`# Response Formatting Requirements
|
||||
|
||||
Always structure your responses using clear markdown formatting:
|
||||
|
||||
- By default don't put information into tables for questions (but do put information into tables when creating or updating files)
|
||||
- Use headings (##, ###) to organise sections, always
|
||||
- Use bullet points or numbered lists for multiple items
|
||||
- Use code blocks with language tags for any code
|
||||
- Use **bold** for key terms and emphasis
|
||||
- Use tables when comparing options or listing structured data
|
||||
- Break long responses into logical sections with headings`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigMarkdown: frontmatter has weird model id", async () => {
|
||||
const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/weird-model-id.md")
|
||||
|
||||
test("should parse and match", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result.data["description"]).toEqual("General coding and planning agent")
|
||||
expect(result.data["mode"]).toEqual("subagent")
|
||||
expect(result.data["model"]).toEqual("synthetic/hf:zai-org/GLM-4.7")
|
||||
expect(result.data["tools"]["write"]).toBeTrue()
|
||||
expect(result.data["tools"]["read"]).toBeTrue()
|
||||
expect(result.data["stuff"]).toBe("This is some stuff\n")
|
||||
|
||||
expect(result.content.trim()).toBe("Strictly follow da rules")
|
||||
})
|
||||
})
|
||||
0
packages/opencode/test/config/plugin.test.ts
Normal file
0
packages/opencode/test/config/plugin.test.ts
Normal file
886
packages/opencode/test/config/tui.test.ts
Normal file
886
packages/opencode/test/config/tui.test.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { CurrentWorkingDirectory } from "@/config/tui-cwd"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer))
|
||||
const winIt = process.platform === "win32" ? it.instance : it.instance.skip
|
||||
|
||||
const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) =>
|
||||
path.join(Global.Path.config, file),
|
||||
)
|
||||
|
||||
const cleanState = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
delete process.env.OPENCODE_CONFIG
|
||||
delete process.env.OPENCODE_TUI_CONFIG
|
||||
yield* Effect.forEach(globalConfigFiles, (file) => fs.remove(file, { force: true }).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
})
|
||||
|
||||
const withCleanState = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
cleanState,
|
||||
() => self,
|
||||
() => cleanState,
|
||||
)
|
||||
|
||||
const withEnv = <A, E, R>(name: string, value: string | undefined, self: Effect.Effect<A, E, R>) =>
|
||||
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
|
||||
}),
|
||||
)
|
||||
|
||||
const withPlatform = <A, E, R>(platform: typeof process.platform, self: Effect.Effect<A, E, R>) =>
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
||||
const getTuiConfig = (directory: string) =>
|
||||
TuiConfig.Service.use((svc) => svc.get()).pipe(
|
||||
Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
|
||||
)
|
||||
|
||||
const getTuiPluginOrigins = (directory: string) =>
|
||||
TuiConfig.Service.use((svc) => svc.pluginOrigins()).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* FSUtil.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.use.get()
|
||||
const tui = yield* getTuiConfig(test.directory)
|
||||
const tuiOrigins = yield* getTuiPluginOrigins(test.directory)
|
||||
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 ?? []
|
||||
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))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("loads tui config with the same precedence order as server config paths", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBe("local")
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("resolves attention config defaults and overrides", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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"),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("migrates tui-specific keys from opencode.json when tui.json does not exist", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = 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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("migrates project legacy tui keys even when global tui.json already exists", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = 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(yield* fs.readFileString(path.join(test.directory, "opencode.json")))
|
||||
expect(server.theme).toBeUndefined()
|
||||
expect(server.tui).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("drops unknown legacy tui keys during migration", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBe("migrated-theme")
|
||||
expect(config.scroll_speed).toBe(2)
|
||||
|
||||
const migrated = JSON.parse(yield* fs.readFileString(path.join(test.directory, "tui.json")))
|
||||
expect(migrated.scroll_speed).toBe(2)
|
||||
expect(migrated.foo).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("skips migration when opencode.jsonc is syntactically invalid", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = 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 }')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("skips migration when tui.json already exists", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
expect(config.theme).toBeUndefined()
|
||||
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("continues loading tui config when legacy source cannot be stripped", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const source = path.join(test.directory, "opencode.json")
|
||||
yield* fs.writeJson(source, { theme: "readonly-theme" })
|
||||
|
||||
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)
|
||||
|
||||
const server = JSON.parse(yield* fs.readFileString(source))
|
||||
expect(server.theme).toBe("readonly-theme")
|
||||
}),
|
||||
() => fs.chmod(source, 0o644).pipe(Effect.ignore),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("migration backup preserves JSONC comments", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeFileString(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
`{
|
||||
// top-level comment
|
||||
"theme": "jsonc-theme",
|
||||
"tui": {
|
||||
// nested comment
|
||||
"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')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("migrates legacy tui keys across multiple opencode.json levels", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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" })
|
||||
|
||||
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* FSUtil.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* FSUtil.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* FSUtil.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")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("merges keybind overrides across precedence layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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" } })
|
||||
|
||||
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("ignores unknown keybind names without dropping valid overrides from the same file", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
keybinds: {
|
||||
session_delete: "ctrl+d",
|
||||
not_a_real_keybind: "ctrl+q",
|
||||
},
|
||||
})
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
expect(config.keybinds.get("session.delete")?.[0]?.key).toBe("ctrl+d")
|
||||
expect(config.keybinds.get("not_a_real_keybind")).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("resolves keybind lookup from canonical keybinds", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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.prompt.submit": "ctrl+s",
|
||||
"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("<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.prompt.submit")?.[0]?.key).toBe("ctrl+s")
|
||||
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"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("keybinds accept OpenTUI binding specs", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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",
|
||||
},
|
||||
})
|
||||
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
winIt("keeps explicit input undo overrides on Windows", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { keybinds: { input_undo: "ctrl+y" } })
|
||||
|
||||
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* FSUtil.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* FSUtil.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([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("keeps explicit configured keybind input undo on Windows", () =>
|
||||
withCleanState(
|
||||
withPlatform(
|
||||
"win32",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
keybinds: {
|
||||
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* FSUtil.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")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("does not derive tui path from OPENCODE_CONFIG", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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" })
|
||||
|
||||
yield* withEnv(
|
||||
"OPENCODE_CONFIG",
|
||||
path.join(customDir, "opencode.json"),
|
||||
Effect.gen(function* () {
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("applies env and file substitutions in tui.json", () =>
|
||||
withCleanState(
|
||||
withEnv(
|
||||
"TUI_THEME_TEST",
|
||||
"env-theme",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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}" },
|
||||
})
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBe("env-theme")
|
||||
expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("applies file substitutions when first identical token is in a commented line", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBe("resolved-theme")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("loads .opencode/tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tui.json"),
|
||||
JSON.stringify({ diff_style: "stacked" }, null, 2),
|
||||
)
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
expect(config.diff_style).toBe("stacked")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("supports tuple plugin specs with options in tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||
expect(origins).toEqual([
|
||||
{
|
||||
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||
scope: "local",
|
||||
source: path.join(test.directory, "tui.json"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual([
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
])
|
||||
expect(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"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("tracks global and local plugin metadata in merged tui config", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||
expect(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"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("merges plugin_enabled flags across config layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.plugin_enabled).toEqual({
|
||||
"internal:sidebar-context": false,
|
||||
"demo.plugin": false,
|
||||
"local.plugin": true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("silently skips malformed tui.json - load failures degrade to {}", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.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 = yield* getTuiConfig(test.directory)
|
||||
expect(config.theme).toBe("fallback")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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* FSUtil.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" }))
|
||||
|
||||
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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user