summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-16 02:03:03 -0400
committerGitHub <[email protected]>2026-04-16 02:03:03 -0400
commit675a46e23e679c294355435584ae662a7c0903c7 (patch)
treefb5162a7e3565f153db26055a0857d4ac3d6448f
parent150ab07a833f0b10f4af17b3dd713cfedb16a6ff (diff)
downloadopencode-675a46e23e679c294355435584ae662a7c0903c7.tar.gz
opencode-675a46e23e679c294355435584ae662a7c0903c7.zip
CLI perf: reduce deps (#22652)
-rw-r--r--bun.lock2
-rw-r--r--packages/opencode/.gitignore3
-rw-r--r--packages/opencode/package.json1
-rwxr-xr-xpackages/opencode/script/schema.ts2
-rw-r--r--packages/opencode/src/acp/agent.ts3
-rw-r--r--packages/opencode/src/cli/cmd/mcp.ts5
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx7
-rw-r--r--packages/opencode/src/cli/cmd/tui/attach.ts9
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/error-component.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts0
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx53
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/cwd.ts5
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts (renamed from packages/opencode/src/config/tui-migrate.ts)15
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-schema.ts (renamed from packages/opencode/src/config/tui-schema.ts)7
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts208
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/keybind.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/local.tsx40
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx8
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/tui-config.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/layer.ts6
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/api.tsx6
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts109
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts13
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/toast.tsx5
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/clipboard.ts25
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/scroll.ts2
-rw-r--r--packages/opencode/src/cli/cmd/upgrade.ts5
-rw-r--r--packages/opencode/src/cli/effect/runtime.ts20
-rw-r--r--packages/opencode/src/cli/error.ts88
-rw-r--r--packages/opencode/src/cli/network.ts5
-rw-r--r--packages/opencode/src/cli/upgrade.ts5
-rw-r--r--packages/opencode/src/config/config.ts3
-rw-r--r--packages/opencode/src/config/index.ts1
-rw-r--r--packages/opencode/src/config/keybinds.ts164
-rw-r--r--packages/opencode/src/config/paths.ts4
-rw-r--r--packages/opencode/src/config/plugin.ts75
-rw-r--r--packages/opencode/src/config/tui.ts212
-rw-r--r--packages/opencode/src/effect/app-runtime.ts2
-rw-r--r--packages/opencode/src/effect/observability.ts8
-rw-r--r--packages/opencode/src/file/file.ts5
-rw-r--r--packages/opencode/src/index.ts5
-rw-r--r--packages/opencode/src/installation/installation.ts14
-rw-r--r--packages/opencode/src/installation/meta.ts7
-rw-r--r--packages/opencode/src/installation/version.ts8
-rw-r--r--packages/opencode/src/mcp/mcp.ts5
-rw-r--r--packages/opencode/src/npm/npm.ts3
-rw-r--r--packages/opencode/src/plugin/codex.ts7
-rw-r--r--packages/opencode/src/plugin/github-copilot/copilot.ts9
-rw-r--r--packages/opencode/src/plugin/install.ts2
-rw-r--r--packages/opencode/src/plugin/loader.ts26
-rw-r--r--packages/opencode/src/project/bootstrap.ts2
-rw-r--r--packages/opencode/src/provider/provider.ts108
-rw-r--r--packages/opencode/src/server/instance/global.ts3
-rw-r--r--packages/opencode/src/session/llm.ts3
-rw-r--r--packages/opencode/src/session/session.ts3
-rw-r--r--packages/opencode/src/storage/db.ts6
-rw-r--r--packages/opencode/src/temporary.ts33
-rw-r--r--packages/opencode/src/util/filesystem.ts4
-rw-r--r--packages/opencode/test/cli/tui/plugin-add.test.ts22
-rw-r--r--packages/opencode/test/cli/tui/plugin-install.test.ts8
-rw-r--r--packages/opencode/test/cli/tui/plugin-lifecycle.test.ts16
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts58
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-pure.test.ts9
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader.test.ts95
-rw-r--r--packages/opencode/test/cli/tui/plugin-toggle.test.ts16
-rw-r--r--packages/opencode/test/cli/tui/thread.test.ts11
-rw-r--r--packages/opencode/test/config/config.test.ts21
-rw-r--r--packages/opencode/test/config/plugin.test.ts0
-rw-r--r--packages/opencode/test/config/tui.test.ts493
-rw-r--r--packages/opencode/test/file/index.test.ts4
-rw-r--r--packages/opencode/test/fixture/tui-runtime.ts26
-rw-r--r--packages/opencode/test/storage/db.test.ts6
-rw-r--r--packages/opencode/test/tool/read.test.ts1
-rw-r--r--packages/opencode/test/util/filesystem.test.ts26
-rwxr-xr-xpackages/opencode/time.ts4
-rwxr-xr-xpackages/opencode/trace-imports.ts153
-rw-r--r--packages/opencode/tsconfig.json3
-rw-r--r--packages/shared/package.json7
-rw-r--r--packages/shared/src/npm.ts25
-rw-r--r--packages/shared/src/util/error.ts6
-rw-r--r--packages/shared/src/util/flock.ts10
-rw-r--r--packages/shared/tsconfig.json9
84 files changed, 1413 insertions, 1009 deletions
diff --git a/bun.lock b/bun.lock
index a011a648f..644de37f2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -523,7 +523,9 @@
"zod": "catalog:",
},
"devDependencies": {
+ "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
+ "@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
},
},
diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore
index 348f05113..2b20d9c31 100644
--- a/packages/opencode/.gitignore
+++ b/packages/opencode/.gitignore
@@ -1,6 +1,9 @@
research
dist
+dist-*
gen
app.log
src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
+script/build-*.ts
+temporary-*.md
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index c0f82c149..7ed33ebe0 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -14,6 +14,7 @@
"fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
+ "dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit"
},
"bin": {
diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts
index cf63d6743..c0f302f21 100755
--- a/packages/opencode/script/schema.ts
+++ b/packages/opencode/script/schema.ts
@@ -2,7 +2,7 @@
import { z } from "zod"
import { Config } from "../src/config"
-import { TuiConfig } from "../src/config"
+import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 57cce6668..53bc7ed5f 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -49,6 +49,7 @@ import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
+import { InstallationVersion } from "@/installation/version"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@@ -570,7 +571,7 @@ export namespace ACP {
authMethods: [authMethod],
agentInfo: {
name: "OpenCode",
- version: Installation.VERSION,
+ version: InstallationVersion,
},
}
}
diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts
index 06c03d9f4..dc6d5e889 100644
--- a/packages/opencode/src/cli/cmd/mcp.ts
+++ b/packages/opencode/src/cli/cmd/mcp.ts
@@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
+import { InstallationVersion } from "../../installation/version"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
@@ -697,7 +698,7 @@ export const McpDebugCommand = cmd({
params: {
protocolVersion: "2024-11-05",
capabilities: {},
- clientInfo: { name: "opencode-debug", version: Installation.VERSION },
+ clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
@@ -746,7 +747,7 @@ export const McpDebugCommand = cmd({
try {
const client = new Client({
name: "opencode-debug",
- version: Installation.VERSION,
+ version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 5102169b5..8255c007d 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -57,7 +57,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
@@ -235,7 +235,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer,
})
const [ready, setReady] = createSignal(false)
- TuiPluginRuntime.init(api)
+ TuiPluginRuntime.init({
+ api,
+ config: tuiConfig,
+ })
.catch((error) => {
console.error("Failed to load TUI plugins", error)
})
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
index 9fcbf4c1f..9a93f3f57 100644
--- a/packages/opencode/src/cli/cmd/tui/attach.ts
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -2,9 +2,7 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
-import { TuiConfig } from "@/config"
-import { Instance } from "@/project/instance"
-import { existsSync } from "fs"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -66,10 +64,7 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
- const config = await Instance.provide({
- directory: directory && existsSync(directory) ? directory : process.cwd(),
- fn: () => TuiConfig.get(),
- })
+ const config = await TuiConfig.get()
await tui({
url: args.url,
config,
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
index 365a22445..017e52d2b 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
@@ -20,7 +20,7 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
- current={local.agent.current().name}
+ current={local.agent.current()?.name}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)
diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx
index 38df35a04..c74d3bbc6 100644
--- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
@@ -53,7 +53,7 @@ export function ErrorComponent(props: {
)
}
- issueURL.searchParams.set("opencode-version", Installation.VERSION)
+ issueURL.searchParams.set("opencode-version", InstallationVersion)
const copyIssueURL = () => {
void Clipboard.copy(issueURL.toString()).then(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/cwd.ts
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 20003d846..b4ab82729 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -602,6 +602,8 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
+ const agent = local.agent.current()
+ if (!agent) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
@@ -662,7 +664,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") {
void sdk.client.session.shell({
sessionID,
- agent: local.agent.current().name,
+ agent: agent.name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
@@ -689,7 +691,7 @@ export function Prompt(props: PromptProps) {
sessionID,
command: command.slice(1),
arguments: args,
- agent: local.agent.current().name,
+ agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
@@ -706,7 +708,7 @@ export function Prompt(props: PromptProps) {
sessionID,
...selectedModel,
messageID,
- agent: local.agent.current().name,
+ agent: agent.name,
model: selectedModel,
variant,
parts: [
@@ -829,7 +831,9 @@ export function Prompt(props: PromptProps) {
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (store.mode === "shell") return theme.primary
- return local.agent.color(local.agent.current().name)
+ const agent = local.agent.current()
+ if (!agent) return theme.border
+ return local.agent.color(agent.name)
})
const showVariant = createMemo(() => {
@@ -851,7 +855,8 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
- const color = local.agent.color(local.agent.current().name)
+ const agent = local.agent.current()
+ const color = agent ? local.agent.color(agent.name) : theme.border
return {
frames: createFrames({
color,
@@ -1041,7 +1046,7 @@ export function Prompt(props: PromptProps) {
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
- const mime = Filesystem.mimeType(filepath)
+ const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
@@ -1107,22 +1112,26 @@ export function Prompt(props: PromptProps) {
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
- <text fg={highlight()}>
- {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
- </text>
- <Show when={store.mode === "normal"}>
- <box flexDirection="row" gap={1}>
- <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
- {local.model.parsed().model}
- </text>
- <text fg={theme.textMuted}>{currentProviderLabel()}</text>
- <Show when={showVariant()}>
- <text fg={theme.textMuted}>·</text>
- <text>
- <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
- </text>
- </Show>
- </box>
+ <Show when={local.agent.current()} fallback={<box height={1} />}>
+ {(agent) => (
+ <>
+ <text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
+ <Show when={store.mode === "normal"}>
+ <box flexDirection="row" gap={1}>
+ <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
+ {local.model.parsed().model}
+ </text>
+ <text fg={theme.textMuted}>{currentProviderLabel()}</text>
+ <Show when={showVariant()}>
+ <text fg={theme.textMuted}>·</text>
+ <text>
+ <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
+ </text>
+ </Show>
+ </box>
+ </Show>
+ </>
+ )}
</Show>
</box>
<Show when={hasRightContent()}>
diff --git a/packages/opencode/src/cli/cmd/tui/config/cwd.ts b/packages/opencode/src/cli/cmd/tui/config/cwd.ts
new file mode 100644
index 000000000..22f342d8d
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/config/cwd.ts
@@ -0,0 +1,5 @@
+import { Context } from "effect"
+
+export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
+ defaultValue: () => process.cwd(),
+})
diff --git a/packages/opencode/src/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
index ed19474be..3ce5c4b73 100644
--- a/packages/opencode/src/config/tui-migrate.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
@@ -2,13 +2,11 @@ import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
-import * as ConfigPaths from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
-import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
-import { Log } from "@/util"
-import { Filesystem } from "@/util"
import { Global } from "@/global"
+import { Filesystem, Log } from "@/util"
+import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })
@@ -26,9 +24,9 @@ const TuiLegacy = z
.strip()
interface MigrateInput {
+ cwd: string
directories: string[]
custom?: string
- managed: string
}
/**
@@ -134,16 +132,13 @@ async function backupAndStripLegacy(file: string, source: string) {
})
}
-async function opencodeFiles(input: { directories: string[]; managed: string }) {
- const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
- ? []
- : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
+async function opencodeFiles(input: { directories: string[]; cwd: string }) {
+ const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
- files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {
diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
index 3be988370..66569efea 100644
--- a/packages/opencode/src/config/tui-schema.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
@@ -1,9 +1,10 @@
import z from "zod"
-import * as Config from "./config"
+import { ConfigPlugin } from "@/config/plugin"
+import { ConfigKeybinds } from "@/config/keybinds"
const KeybindOverride = z
.object(
- Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
+ Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
@@ -30,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
- plugin: Config.PluginSpec.array().optional(),
+ plugin: ConfigPlugin.Spec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
new file mode 100644
index 000000000..6f2c161fb
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -0,0 +1,208 @@
+import z from "zod"
+import { mergeDeep, unique } from "remeda"
+import { Context, Effect, Fiber, Layer } from "effect"
+import * as ConfigPaths from "@/config/paths"
+import { migrateTuiConfig } from "./tui-migrate"
+import { TuiInfo } from "./tui-schema"
+import { Flag } from "@/flag/flag"
+import { isRecord } from "@/util/record"
+import { Global } from "@/global"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Npm } from "@opencode-ai/shared/npm"
+import { CurrentWorkingDirectory } from "./cwd"
+import { ConfigPlugin } from "@/config/plugin"
+import { ConfigKeybinds } from "@/config/keybinds"
+import { InstallationLocal, InstallationVersion } from "@/installation/version"
+import { makeRuntime } from "@/cli/effect/runtime"
+import { Filesystem, Log } from "@/util"
+
+export namespace TuiConfig {
+ const log = Log.create({ service: "tui.config" })
+
+ export const Info = TuiInfo
+
+ type Acc = {
+ result: Info
+ }
+
+ type State = {
+ config: Info
+ deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
+ }
+
+ export type Info = z.output<typeof Info> & {
+ // Internal resolved plugin list used by runtime loading.
+ plugin_origins?: ConfigPlugin.Origin[]
+ }
+
+ export interface Interface {
+ readonly get: () => Effect.Effect<Info>
+ readonly waitForDependencies: () => Effect.Effect<void>
+ }
+
+ export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
+
+ function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
+ if (Filesystem.contains(ctx.directory, file)) return "local"
+ // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
+ return "global"
+ }
+
+ function customPath() {
+ return Flag.OPENCODE_TUI_CONFIG
+ }
+
+ function normalize(raw: Record<string, unknown>) {
+ const data = { ...raw }
+ if (!("tui" in data)) return data
+ if (!isRecord(data.tui)) {
+ delete data.tui
+ return data
+ }
+
+ const tui = data.tui
+ delete data.tui
+ return {
+ ...tui,
+ ...data,
+ }
+ }
+
+ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
+ const data = await loadFile(file)
+ acc.result = mergeDeep(acc.result, data)
+ if (!data.plugin?.length) return
+
+ const scope = pluginScope(file, ctx)
+ const plugins = ConfigPlugin.deduplicatePluginOrigins([
+ ...(acc.result.plugin_origins ?? []),
+ ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+ ])
+ acc.result.plugin = plugins.map((item) => item.spec)
+ acc.result.plugin_origins = plugins
+ }
+
+ async function loadState(ctx: { directory: string }) {
+ let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+ const directories = await ConfigPaths.directories(ctx.directory)
+ const custom = customPath()
+ await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
+ // Re-compute after migration since migrateTuiConfig may have created new tui.json files
+ projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+
+ const acc: Acc = {
+ result: {},
+ }
+
+ for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+ await mergeFile(acc, file, ctx)
+ }
+
+ if (custom) {
+ await mergeFile(acc, custom, ctx)
+ log.debug("loaded custom tui config", { path: custom })
+ }
+
+ for (const file of projectFiles) {
+ await mergeFile(acc, file, ctx)
+ }
+
+ const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
+
+ for (const dir of dirs) {
+ if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+ for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+ await mergeFile(acc, file, ctx)
+ }
+ }
+
+ const keybinds = { ...(acc.result.keybinds ?? {}) }
+ if (process.platform === "win32") {
+ // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
+ keybinds.terminal_suspend = "none"
+ keybinds.input_undo ??= unique([
+ "ctrl+z",
+ ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
+ ]).join(",")
+ }
+ acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
+
+ return {
+ config: acc.result,
+ dirs: acc.result.plugin?.length ? dirs : [],
+ }
+ }
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const directory = yield* CurrentWorkingDirectory
+ const npm = yield* Npm.Service
+ const data = yield* Effect.promise(() => loadState({ directory }))
+ const deps = yield* Effect.forEach(
+ data.dirs,
+ (dir) =>
+ npm
+ .install(dir, {
+ add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+ })
+ .pipe(Effect.forkScoped),
+ {
+ concurrency: "unbounded",
+ },
+ )
+
+ const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+
+ const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
+ Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
+ )
+ return Service.of({ get, waitForDependencies })
+ }).pipe(Effect.withSpan("TuiConfig.layer")),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
+
+ const { runPromise } = makeRuntime(Service, defaultLayer)
+
+ export async function waitForDependencies() {
+ await runPromise((svc) => svc.waitForDependencies())
+ }
+
+ export async function get() {
+ return runPromise((svc) => svc.get())
+ }
+
+ async function loadFile(filepath: string): Promise<Info> {
+ const text = await ConfigPaths.readFile(filepath)
+ if (!text) return {}
+ return load(text, filepath).catch((error) => {
+ log.warn("failed to load tui config", { path: filepath, error })
+ return {}
+ })
+ }
+
+ async function load(text: string, configFilepath: string): Promise<Info> {
+ const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
+ if (!isRecord(raw)) return {}
+
+ // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+ // (mirroring the old opencode.json shape) still get their settings applied.
+ const normalized = normalize(raw)
+
+ const parsed = Info.safeParse(normalized)
+ if (!parsed.success) {
+ log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
+ return {}
+ }
+
+ const data = parsed.data
+ if (data.plugin) {
+ for (let i = 0; i < data.plugin.length; i++) {
+ data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
+ }
+ }
+
+ return data
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
index b1dcdd780..bf40f6b87 100644
--- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
@@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util"
import { pipe, mapValues } from "remeda"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index 4c298ec11..bb73c6537 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
+import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
@@ -6,14 +7,20 @@ import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { iife } from "@/util/iife"
-import { createSimpleContext } from "./helper"
import { useToast } from "../ui/toast"
-import { Provider } from "@/provider"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util"
+export function parseModel(model: string) {
+ const [providerID, ...rest] = model.split("/")
+ return {
+ providerID: providerID,
+ modelID: rest.join("/"),
+ }
+}
+
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
@@ -37,10 +44,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
- const [agentStore, setAgentStore] = createStore<{
- current: string
- }>({
- current: agents()[0].name,
+ const [agentStore, setAgentStore] = createStore({
+ current: undefined as string | undefined,
})
const { theme } = useTheme()
const colors = createMemo(() => [
@@ -57,7 +62,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
- return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
+ return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
},
set(name: string) {
if (!agents().some((x) => x.name === name))
@@ -153,7 +158,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const fallbackModel = createMemo(() => {
if (args.model) {
- const { providerID, modelID } = Provider.parseModel(args.model)
+ const { providerID, modelID } = parseModel(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@@ -163,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
if (sync.data.config.model) {
- const { providerID, modelID } = Provider.parseModel(sync.data.config.model)
+ const { providerID, modelID } = parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@@ -194,8 +199,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const a = agent.current()
return (
getFirstValidModel(
- () => modelStore.model[a.name],
- () => a.model,
+ () => a && modelStore.model[a.name],
+ () => a && a.model,
fallbackModel,
) ?? undefined
)
@@ -240,7 +245,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
- setModelStore("model", agent.current().name, { ...val })
+ const a = agent.current()
+ if (!a) return
+ setModelStore("model", a.name, { ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
@@ -266,7 +273,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const next = favorites[index]
if (!next) return
- setModelStore("model", agent.current().name, { ...next })
+ const a = agent.current()
+ if (!a) return
+ setModelStore("model", a.name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
@@ -285,7 +294,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return
}
- setModelStore("model", agent.current().name, model)
+ const a = agent.current()
+ if (!a) return
+ setModelStore("model", a.name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
@@ -387,6 +398,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
+ if (!value) return
if (value.model) {
if (isModelValid(value.model))
model.set({
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 2558f9751..46227e28a 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -29,7 +29,7 @@ import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util"
-import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
+import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
- console_state: ConsoleStateType
+ console_state: ConsoleState
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@@ -363,7 +363,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
- .then((x) => ConsoleState.parse(x.data))
+ .then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
@@ -378,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
]
await Promise.all(blockingRequests)
- .then(() => {
+ .then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
index cfe59ba80..05fdd025c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
@@ -1,4 +1,4 @@
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts
new file mode 100644
index 000000000..734106f8a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/layer.ts
@@ -0,0 +1,6 @@
+import { Layer } from "effect"
+import { TuiConfig } from "./config/tui"
+import { Npm } from "@opencode-ai/shared/npm"
+import { Observability } from "@/effect/observability"
+
+export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
index 42988fcb1..d2b495ca3 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
+++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
@@ -8,7 +8,7 @@ import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
@@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
type RouteEntry = {
key: symbol
@@ -189,7 +189,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
function appApi(): TuiPluginApi["app"] {
return {
get version() {
- return Installation.VERSION
+ return InstallationVersion
},
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
index da003607c..af37ffbd7 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
+++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
@@ -1,4 +1,4 @@
-import "@opentui/solid/runtime-plugin-support"
+// import "@opentui/solid/runtime-plugin-support"
import {
type TuiDispose,
type TuiPlugin,
@@ -12,13 +12,10 @@ import {
} from "@opencode-ai/plugin/tui"
import path from "path"
import { fileURLToPath } from "url"
-
-import { Config } from "@/config"
-import { TuiConfig } from "@/config"
+import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
-import { Instance } from "@/project/instance"
import {
readPackageThemes,
readPluginId,
@@ -39,16 +36,17 @@ import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
+import { ConfigPlugin } from "@/config/plugin"
type PluginLoad = {
- options: Config.PluginOptions | undefined
+ options: ConfigPlugin.Options | undefined
spec: string
target: string
retry: boolean
source: PluginSource | "internal"
id: string
module: TuiPluginModule
- origin: Config.PluginOrigin
+ origin: ConfigPlugin.Origin
theme_root: string
theme_files: string[]
}
@@ -77,7 +75,7 @@ type RuntimeState = {
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
- pending: Map<string, Config.PluginOrigin>
+ pending: Map<string, ConfigPlugin.Origin>
}
const log = Log.create({ service: "tui.plugin" })
@@ -147,7 +145,7 @@ function resolveRoot(root: string) {
}
function createThemeInstaller(
- meta: Config.PluginOrigin,
+ meta: ConfigPlugin.Origin,
root: string,
spec: string,
plugin: PluginEntry,
@@ -590,7 +588,7 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
-async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
+async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
return PluginLoader.loadExternal({
items: list,
kind: "tui",
@@ -745,7 +743,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok }
}
-function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
+function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
return {
spec,
scope: "local",
@@ -786,19 +784,12 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
- const next = Config.pluginSpecifier(cfg.spec)
+ const next = ConfigPlugin.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
}
-
- const ready = await Instance.provide({
- directory: state.directory,
- fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
- }).catch((error) => {
- fail("failed to add tui plugin", { path: next, error })
- return [] as PluginLoad[]
- })
+ const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
if (!ready.length) {
return false
}
@@ -905,7 +896,7 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file
- const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
+ const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
state.pending.set(spec, {
spec: next,
scope: global ? "global" : "local",
@@ -926,7 +917,7 @@ export namespace TuiPluginRuntime {
let runtime: RuntimeState | undefined
export const Slot = View
- export async function init(api: HostPluginApi) {
+ export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -936,7 +927,7 @@ export namespace TuiPluginRuntime {
}
dir = cwd
- loaded = load(api)
+ loaded = load(input)
return loaded
}
@@ -975,7 +966,8 @@ export namespace TuiPluginRuntime {
}
}
- async function load(api: Api) {
+ async function load(input: { api: Api; config: TuiConfig.Info }) {
+ const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
@@ -987,45 +979,40 @@ export namespace TuiPluginRuntime {
pending: new Map(),
}
runtime = next
+ try {
+ const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
+ if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
+ log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
+ }
- await Instance.provide({
- directory: cwd,
- fn: async () => {
- const config = await TuiConfig.get()
- const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
- if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
- log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
- }
-
- for (const item of INTERNAL_TUI_PLUGINS) {
- log.info("loading internal tui plugin", { id: item.id })
- const entry = loadInternalPlugin(item)
- const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
- addPluginEntry(next, {
- id: entry.id,
- load: entry,
- meta,
- themes: {},
- plugin: entry.module.tui,
- enabled: true,
- })
- }
+ for (const item of INTERNAL_TUI_PLUGINS) {
+ log.info("loading internal tui plugin", { id: item.id })
+ const entry = loadInternalPlugin(item)
+ const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
+ addPluginEntry(next, {
+ id: entry.id,
+ load: entry,
+ meta,
+ themes: {},
+ plugin: entry.module.tui,
+ enabled: true,
+ })
+ }
- const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
- await addExternalPluginEntries(next, ready)
-
- applyInitialPluginEnabledState(next, config)
- for (const plugin of next.plugins) {
- if (!plugin.enabled) continue
- // Keep plugin execution sequential for deterministic side effects:
- // command registration order affects keybind/command precedence,
- // route registration is last-wins when ids collide,
- // and hook chains rely on stable plugin ordering.
- await activatePluginEntry(next, plugin, false)
- }
- },
- }).catch((error) => {
+ const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
+ await addExternalPluginEntries(next, ready)
+
+ applyInitialPluginEnabledState(next, config)
+ for (const plugin of next.plugins) {
+ if (!plugin.enabled) continue
+ // Keep plugin execution sequential for deterministic side effects:
+ // command registration order affects keybind/command precedence,
+ // route registration is last-wins when ids collide,
+ // and hook chains rely on stable plugin ordering.
+ await activatePluginEntry(next, plugin, false)
+ }
+ } catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
- })
+ }
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 3c87cfe47..06bc27064 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -2,7 +2,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
-import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
@@ -64,7 +64,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<span style={{ fg: theme.text }}>
<b>Code</b>
</span>{" "}
- <span>{Installation.VERSION}</span>
+ <span>{InstallationVersion}</span>
</text>
</TuiPluginRuntime.Slot>
</box>
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 89b32d166..96ceb905c 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
import { Log } from "@/util"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
-import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
+import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
-import { TuiConfig } from "@/config"
-import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"
+import { TuiConfig } from "./config/tui"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -177,12 +176,9 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
- const config = await Instance.provide({
- directory: cwd,
- fn: () => TuiConfig.get(),
- })
+ const config = await TuiConfig.get()
- const network = await resolveNetworkOptions(args)
+ const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
@@ -237,3 +233,4 @@ export const TuiThreadCommand = cmd({
process.exit(0)
},
})
+// scratch
diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
index 36095580f..f534d90b7 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
@@ -5,7 +5,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import z from "zod"
-import { TuiEvent } from "../event"
+import { type TuiEvent } from "../event"
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
@@ -56,8 +56,7 @@ function init() {
const toast = {
show(options: ToastOptions) {
- const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
- const { duration, ...currentToast } = parsedOptions
+ const { duration, ...currentToast } = options
setStore("currentToast", currentToast)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
index 6968b07eb..8c535833c 100644
--- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
@@ -1,12 +1,21 @@
import { platform, release } from "os"
-import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
-import { Filesystem } from "../../../../util"
-import { Process } from "../../../../util"
-import { which } from "../../../../util/which"
+import * as Filesystem from "../../../../util/filesystem"
+import * as Process from "../../../../util/process"
+
+// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
+const getWhich = lazy(async () => {
+ const { which } = await import("../../../../util/which")
+ return which
+})
+
+const getClipboardy = lazy(async () => {
+ const { default: clipboardy } = await import("clipboardy")
+ return clipboardy
+})
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -94,14 +103,16 @@ export async function read(): Promise<Content | undefined> {
}
}
+ const clipboardy = await getClipboardy()
const text = await clipboardy.read().catch(() => {})
if (text) {
return { data: text, mime: "text/plain" }
}
}
-const getCopyMethod = lazy(() => {
+const getCopyMethod = lazy(async () => {
const os = platform()
+ const which = await getWhich()
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
@@ -180,11 +191,13 @@ const getCopyMethod = lazy(() => {
console.log("clipboard: no native support")
return async (text: string) => {
+ const clipboardy = await getClipboardy()
await clipboardy.write(text).catch(() => {})
}
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
- await getCopyMethod()(text)
+ const method = await getCopyMethod()
+ await method(text)
}
diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts
index d27bdb90c..30d006963 100644
--- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts
@@ -1,5 +1,5 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
-import type { TuiConfig } from "@/config"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts
index 3ffa0f228..b80648c24 100644
--- a/packages/opencode/src/cli/cmd/upgrade.ts
+++ b/packages/opencode/src/cli/cmd/upgrade.ts
@@ -3,6 +3,7 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
+import { InstallationVersion } from "../../installation/version"
export const UpgradeCommand = {
command: "upgrade [target]",
@@ -47,13 +48,13 @@ export const UpgradeCommand = {
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
- if (Installation.VERSION === target) {
+ if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
prompts.outro("Done")
return
}
- prompts.log.info(`From ${Installation.VERSION} → ${target}`)
+ prompts.log.info(`From ${InstallationVersion} → ${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/cli/effect/runtime.ts
new file mode 100644
index 000000000..4d85fa55b
--- /dev/null
+++ b/packages/opencode/src/cli/effect/runtime.ts
@@ -0,0 +1,20 @@
+import { Observability } from "@/effect/observability"
+import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
+
+export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
+ let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+ const getRuntime = () =>
+ (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
+
+ return {
+ runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+ runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+ getRuntime().runPromiseExit(service.use(fn), options),
+ runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+ getRuntime().runPromise(service.use(fn), options),
+ runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
+ runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
+ }
+}
diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts
index 735f1a721..89b557e2d 100644
--- a/packages/opencode/src/cli/error.ts
+++ b/packages/opencode/src/cli/error.ts
@@ -1,48 +1,80 @@
-import { AccountServiceError, AccountTransportError } from "@/account"
-import { ConfigMarkdown } from "@/config"
+import { NamedError } from "@opencode-ai/shared/util/error"
import { errorFormat } from "@/util/error"
-import { Config } from "../config"
-import { MCP } from "../mcp"
-import { Provider } from "../provider"
-import { UI } from "./ui"
+
+interface ErrorLike {
+ name?: string
+ _tag?: string
+ message?: string
+ data?: Record<string, any>
+}
+
+function isTaggedError(error: unknown, tag: string): boolean {
+ return (
+ typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
+ )
+}
export function FormatError(input: unknown) {
- if (MCP.Failed.isInstance(input))
- return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
- if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
- return input.message
+ // MCPFailed: { name: string }
+ if (NamedError.hasName(input, "MCPFailed")) {
+ return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
}
- if (Provider.ModelNotFoundError.isInstance(input)) {
- const { providerID, modelID, suggestions } = input.data
+
+ // AccountServiceError, AccountTransportError: TaggedErrorClass
+ if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) {
+ return (input as ErrorLike).message ?? ""
+ }
+
+ // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
+ if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
+ const data = (input as ErrorLike).data
+ const suggestions = data?.suggestions as string[] | undefined
return [
- `Model not found: ${providerID}/${modelID}`,
+ `Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
].join("\n")
}
- if (Provider.InitError.isInstance(input)) {
- return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
+
+ // ProviderInitError: { providerID: string }
+ if (NamedError.hasName(input, "ProviderInitError")) {
+ return `Failed to initialize provider "${(input as ErrorLike).data?.providerID}". Check credentials and configuration.`
}
- if (Config.JsonError.isInstance(input)) {
- return (
- `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
- )
+
+ // ConfigJsonError: { path: string, message?: string }
+ if (NamedError.hasName(input, "ConfigJsonError")) {
+ const data = (input as ErrorLike).data
+ return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
}
- if (Config.ConfigDirectoryTypoError.isInstance(input)) {
- return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
+
+ // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
+ if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
+ const data = (input as ErrorLike).data
+ return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
}
- if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
- return input.data.message
+
+ // ConfigFrontmatterError: { message: string }
+ if (NamedError.hasName(input, "ConfigFrontmatterError")) {
+ return (input as ErrorLike).data?.message ?? ""
}
- if (Config.InvalidError.isInstance(input))
+
+ // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
+ if (NamedError.hasName(input, "ConfigInvalidError")) {
+ const data = (input as ErrorLike).data
+ const path = data?.path
+ const message = data?.message
+ const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
return [
- `Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
- (input.data.message ? `: ${input.data.message}` : ""),
- ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
+ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
+ ...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
].join("\n")
+ }
- if (UI.CancelledError.isInstance(input)) return ""
+ // UICancelledError: void (no data)
+ if (NamedError.hasName(input, "UICancelledError")) {
+ return ""
+ }
}
export function FormatUnknownError(input: unknown): string {
diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts
index ea281aafb..a489ea14c 100644
--- a/packages/opencode/src/cli/network.ts
+++ b/packages/opencode/src/cli/network.ts
@@ -36,9 +36,12 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
export function withNetworkOptions<T>(yargs: Argv<T>) {
return yargs.options(options)
}
-
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
+ return resolveNetworkOptionsNoConfig(args, config)
+}
+
+export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
index 2628f9673..7c6f08874 100644
--- a/packages/opencode/src/cli/upgrade.ts
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -3,6 +3,7 @@ import { Config } from "@/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
@@ -15,10 +16,10 @@ export async function upgrade() {
return
}
- if (Installation.VERSION === latest) return
+ if (InstallationVersion === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
- const kind = Installation.getReleaseType(Installation.VERSION, latest)
+ const kind = Installation.getReleaseType(InstallationVersion, latest)
if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 66471e908..ecdf20c89 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -21,6 +21,7 @@ import {
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import * as ConfigMarkdown from "./markdown"
import { existsSync } from "fs"
import { Bus } from "@/bus"
@@ -1266,7 +1267,7 @@ export const layer: Layer.Layer<
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
- const target = Installation.isLocal() ? "*" : Installation.VERSION
+ const target = Installation.isLocal() ? "*" : InstallationVersion
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts
index d878fc99a..fbcca1aa9 100644
--- a/packages/opencode/src/config/index.ts
+++ b/packages/opencode/src/config/index.ts
@@ -1,4 +1,3 @@
export * as Config from "./config"
export * as ConfigMarkdown from "./markdown"
export * as ConfigPaths from "./paths"
-export * as TuiConfig from "./tui"
diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts
new file mode 100644
index 000000000..9b8d9e283
--- /dev/null
+++ b/packages/opencode/src/config/keybinds.ts
@@ -0,0 +1,164 @@
+import z from "zod"
+
+export namespace ConfigKeybinds {
+ export const Keybinds = z
+ .object({
+ leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
+ app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
+ editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
+ theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
+ sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
+ scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
+ username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
+ status_view: z.string().optional().default("<leader>s").describe("View status"),
+ session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
+ session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
+ session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
+ session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
+ session_fork: z.string().optional().default("none").describe("Fork session from message"),
+ session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
+ session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
+ stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
+ model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
+ model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
+ session_share: z.string().optional().default("none").describe("Share current session"),
+ session_unshare: z.string().optional().default("none").describe("Unshare current session"),
+ session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
+ session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
+ messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
+ messages_page_down: z
+ .string()
+ .optional()
+ .default("pagedown,ctrl+alt+f")
+ .describe("Scroll messages down by one page"),
+ messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
+ messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
+ messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
+ messages_half_page_down: z
+ .string()
+ .optional()
+ .default("ctrl+alt+d")
+ .describe("Scroll messages down by half page"),
+ messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
+ messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
+ messages_next: z.string().optional().default("none").describe("Navigate to next message"),
+ messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
+ messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
+ messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
+ messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
+ messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
+ messages_toggle_conceal: z
+ .string()
+ .optional()
+ .default("<leader>h")
+ .describe("Toggle code block concealment in messages"),
+ tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
+ model_list: z.string().optional().default("<leader>m").describe("List available models"),
+ model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
+ model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
+ model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
+ model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
+ command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
+ agent_list: z.string().optional().default("<leader>a").describe("List agents"),
+ agent_cycle: z.string().optional().default("tab").describe("Next agent"),
+ agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
+ variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
+ variant_list: z.string().optional().default("none").describe("List model variants"),
+ input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
+ input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
+ input_submit: z.string().optional().default("return").describe("Submit input"),
+ input_newline: z
+ .string()
+ .optional()
+ .default("shift+return,ctrl+return,alt+return,ctrl+j")
+ .describe("Insert newline in input"),
+ input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
+ input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
+ input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
+ input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
+ input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
+ input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
+ input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
+ input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
+ input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
+ input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
+ input_select_line_home: z
+ .string()
+ .optional()
+ .default("ctrl+shift+a")
+ .describe("Select to start of line in input"),
+ input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
+ input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
+ input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
+ input_select_visual_line_home: z
+ .string()
+ .optional()
+ .default("alt+shift+a")
+ .describe("Select to start of visual line in input"),
+ input_select_visual_line_end: z
+ .string()
+ .optional()
+ .default("alt+shift+e")
+ .describe("Select to end of visual line in input"),
+ input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
+ input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
+ input_select_buffer_home: z
+ .string()
+ .optional()
+ .default("shift+home")
+ .describe("Select to start of buffer in input"),
+ input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
+ input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
+ input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
+ input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
+ input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
+ input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
+ input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
+ input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
+ input_word_forward: z
+ .string()
+ .optional()
+ .default("alt+f,alt+right,ctrl+right")
+ .describe("Move word forward in input"),
+ input_word_backward: z
+ .string()
+ .optional()
+ .default("alt+b,alt+left,ctrl+left")
+ .describe("Move word backward in input"),
+ input_select_word_forward: z
+ .string()
+ .optional()
+ .default("alt+shift+f,alt+shift+right")
+ .describe("Select word forward in input"),
+ input_select_word_backward: z
+ .string()
+ .optional()
+ .default("alt+shift+b,alt+shift+left")
+ .describe("Select word backward in input"),
+ input_delete_word_forward: z
+ .string()
+ .optional()
+ .default("alt+d,alt+delete,ctrl+delete")
+ .describe("Delete word forward in input"),
+ input_delete_word_backward: z
+ .string()
+ .optional()
+ .default("ctrl+w,ctrl+backspace,alt+backspace")
+ .describe("Delete word backward in input"),
+ history_previous: z.string().optional().default("up").describe("Previous history item"),
+ history_next: z.string().optional().default("down").describe("Next history item"),
+ session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
+ session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
+ session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
+ session_parent: z.string().optional().default("up").describe("Go to parent session"),
+ terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
+ terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
+ tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
+ plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
+ display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
+ })
+ .strict()
+ .meta({
+ ref: "KeybindsConfig",
+ })
+}
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index 82dde2df9..eeb9d62d3 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -7,11 +7,11 @@ import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
-export async function projectFiles(name: string, directory: string, worktree: string) {
+export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
-export async function directories(directory: string, worktree: string) {
+export async function directories(directory: string, worktree?: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts
new file mode 100644
index 000000000..d13a9d5ad
--- /dev/null
+++ b/packages/opencode/src/config/plugin.ts
@@ -0,0 +1,75 @@
+import { Glob } from "@opencode-ai/shared/util/glob"
+import z from "zod"
+import { pathToFileURL } from "url"
+import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import path from "path"
+
+export namespace ConfigPlugin {
+ const Options = z.record(z.string(), z.unknown())
+ export type Options = z.infer<typeof Options>
+
+ export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
+ export type Spec = z.infer<typeof Spec>
+
+ export type Scope = "global" | "local"
+
+ export type Origin = {
+ spec: Spec
+ source: string
+ scope: Scope
+ }
+
+ export async function load(dir: string) {
+ const plugins: ConfigPlugin.Spec[] = []
+
+ for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
+ cwd: dir,
+ absolute: true,
+ dot: true,
+ symlink: true,
+ })) {
+ plugins.push(pathToFileURL(item).href)
+ }
+ return plugins
+ }
+
+ export function pluginSpecifier(plugin: ConfigPlugin.Spec): string {
+ return Array.isArray(plugin) ? plugin[0] : plugin
+ }
+
+ export function pluginOptions(plugin: Spec): Options | undefined {
+ return Array.isArray(plugin) ? plugin[1] : undefined
+ }
+
+ export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
+ const spec = pluginSpecifier(plugin)
+ if (!isPathPluginSpec(spec)) return plugin
+
+ const base = path.dirname(configFilepath)
+ const file = (() => {
+ if (spec.startsWith("file://")) return spec
+ if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
+ return pathToFileURL(path.resolve(base, spec)).href
+ })()
+
+ const resolved = await resolvePathPluginTarget(file).catch(() => file)
+
+ if (Array.isArray(plugin)) return [resolved, plugin[1]]
+ return resolved
+ }
+
+ export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
+ const seen = new Set<string>()
+ const list: Origin[] = []
+
+ for (const plugin of plugins.toReversed()) {
+ const spec = pluginSpecifier(plugin.spec)
+ const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
+ if (seen.has(name)) continue
+ seen.add(name)
+ list.push(plugin)
+ }
+
+ return list.toReversed()
+ }
+}
diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts
deleted file mode 100644
index 3cde908b0..000000000
--- a/packages/opencode/src/config/tui.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-import { existsSync } from "fs"
-import z from "zod"
-import { mergeDeep, unique } from "remeda"
-import { Context, Effect, Fiber, Layer } from "effect"
-import * as Config from "./config"
-import * as ConfigPaths from "./paths"
-import { migrateTuiConfig } from "./tui-migrate"
-import { TuiInfo } from "./tui-schema"
-import { Flag } from "@/flag/flag"
-import { Log } from "@/util"
-import { isRecord } from "@/util/record"
-import { Global } from "@/global"
-import { InstanceState } from "@/effect"
-import { makeRuntime } from "@/effect/run-service"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-
-const log = Log.create({ service: "tui.config" })
-
-export const Info = TuiInfo
-
-type Acc = {
- result: Info
-}
-
-type State = {
- config: Info
- deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-}
-
-export type Info = z.output<typeof Info> & {
- // Internal resolved plugin list used by runtime loading.
- plugin_origins?: Config.PluginOrigin[]
-}
-
-export interface Interface {
- readonly get: () => Effect.Effect<Info>
- readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
-
-function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
- if (AppFileSystem.contains(ctx.directory, file)) return "local"
- if (ctx.worktree !== "/" && AppFileSystem.contains(ctx.worktree, file)) return "local"
- return "global"
-}
-
-function customPath() {
- return Flag.OPENCODE_TUI_CONFIG
-}
-
-function normalize(raw: Record<string, unknown>) {
- const data = { ...raw }
- if (!("tui" in data)) return data
- if (!isRecord(data.tui)) {
- delete data.tui
- return data
- }
-
- const tui = data.tui
- delete data.tui
- return {
- ...tui,
- ...data,
- }
-}
-
-async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
- const data = await loadFile(file)
- acc.result = mergeDeep(acc.result, data)
- if (!data.plugin?.length) return
-
- const scope = pluginScope(file, ctx)
- const plugins = Config.deduplicatePluginOrigins([
- ...(acc.result.plugin_origins ?? []),
- ...data.plugin.map((spec) => ({ spec, scope, source: file })),
- ])
- acc.result.plugin = plugins.map((item) => item.spec)
- acc.result.plugin_origins = plugins
-}
-
-async function loadState(ctx: { directory: string; worktree: string }) {
- let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
- ? []
- : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
- const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
- const custom = customPath()
- const managed = Config.managedConfigDir()
- await migrateTuiConfig({ directories, custom, managed })
- // Re-compute after migration since migrateTuiConfig may have created new tui.json files
- projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
- ? []
- : await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
-
- const acc: Acc = {
- result: {},
- }
-
- for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
- await mergeFile(acc, file, ctx)
- }
-
- if (custom) {
- await mergeFile(acc, custom, ctx)
- log.debug("loaded custom tui config", { path: custom })
- }
-
- for (const file of projectFiles) {
- await mergeFile(acc, file, ctx)
- }
-
- const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
-
- for (const dir of dirs) {
- if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
- for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
- await mergeFile(acc, file, ctx)
- }
- }
-
- if (existsSync(managed)) {
- for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
- await mergeFile(acc, file, ctx)
- }
- }
-
- const keybinds = { ...acc.result.keybinds }
- if (process.platform === "win32") {
- // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
- keybinds.terminal_suspend = "none"
- keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
- ",",
- )
- }
- acc.result.keybinds = Config.Keybinds.parse(keybinds)
-
- return {
- config: acc.result,
- dirs: acc.result.plugin?.length ? dirs : [],
- }
-}
-
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const cfg = yield* Config.Service
- const state = yield* InstanceState.make<State>(
- Effect.fn("TuiConfig.state")(function* (ctx) {
- const data = yield* Effect.promise(() => loadState(ctx))
- const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
- concurrency: "unbounded",
- })
- return { config: data.config, deps }
- }),
- )
-
- const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
-
- const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
- InstanceState.useEffect(state, (s) =>
- Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
- ),
- )
-
- return Service.of({ get, waitForDependencies })
- }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
-
-const { runPromise } = makeRuntime(Service, defaultLayer)
-
-export async function get() {
- return runPromise((svc) => svc.get())
-}
-
-export async function waitForDependencies() {
- await runPromise((svc) => svc.waitForDependencies())
-}
-
-async function loadFile(filepath: string): Promise<Info> {
- const text = await ConfigPaths.readFile(filepath)
- if (!text) return {}
- return load(text, filepath).catch((error) => {
- log.warn("failed to load tui config", { path: filepath, error })
- return {}
- })
-}
-
-async function load(text: string, configFilepath: string): Promise<Info> {
- const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
- if (!isRecord(raw)) return {}
-
- // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
- // (mirroring the old opencode.json shape) still get their settings applied.
- const normalized = normalize(raw)
-
- const parsed = Info.safeParse(normalized)
- if (!parsed.success) {
- log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
- return {}
- }
-
- const data = parsed.data
- if (data.plugin) {
- for (let i = 0; i < data.plugin.length; i++) {
- data.plugin[i] = await Config.resolvePluginSpec(data.plugin[i], configFilepath)
- }
- }
-
- return data
-}
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index aabafc5b4..f06c41e31 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -47,8 +47,10 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
+import { Npm } from "@opencode-ai/shared/npm"
export const AppLayer = Layer.mergeAll(
+ Npm.defaultLayer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts
index 2f4040113..efd16ffc0 100644
--- a/packages/opencode/src/effect/observability.ts
+++ b/packages/opencode/src/effect/observability.ts
@@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
-import { CHANNEL, VERSION } from "@/installation/meta"
+import { InstallationChannel, InstallationVersion } from "@/installation/version"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
@@ -21,9 +21,9 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
const resource = {
serviceName: "opencode",
- serviceVersion: VERSION,
+ serviceVersion: InstallationVersion,
attributes: {
- "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
+ "deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
@@ -76,3 +76,5 @@ export const layer = !base
return Layer.mergeAll(trace, logs)
}),
)
+
+export const Observability = { enabled, layer }
diff --git a/packages/opencode/src/file/file.ts b/packages/opencode/src/file/file.ts
index 226906591..ee8df2b0b 100644
--- a/packages/opencode/src/file/file.ts
+++ b/packages/opencode/src/file/file.ts
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Git } from "@/git"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Scope } from "effect"
import * as Stream from "effect/Stream"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
@@ -345,6 +345,7 @@ export const layer = Layer.effect(
const appFs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const git = yield* Git.Service
+ const scope = yield* Scope.Scope
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
@@ -419,7 +420,7 @@ export const layer = Layer.effect(
})
const init = Effect.fn("File.init")(function* () {
- yield* ensure()
+ yield* ensure().pipe(Effect.forkIn(scope))
})
const status = Effect.fn("File.status")(function* () {
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index d9f4651fb..67de87c2a 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -11,6 +11,7 @@ import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
+import { InstallationVersion } from "./installation/version"
import { NamedError } from "@opencode-ai/shared/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
@@ -68,7 +69,7 @@ const cli = yargs(args)
.wrap(100)
.help("help", "show help")
.alias("help", "h")
- .version("version", "show version number", Installation.VERSION)
+ .version("version", "show version number", InstallationVersion)
.alias("version", "v")
.option("print-logs", {
describe: "print logs to stderr",
@@ -105,7 +106,7 @@ const cli = yargs(args)
process.env.OPENCODE_PID = String(process.pid)
Log.Default.info("opencode", {
- version: Installation.VERSION,
+ version: InstallationVersion,
args: process.argv.slice(2),
})
diff --git a/packages/opencode/src/installation/installation.ts b/packages/opencode/src/installation/installation.ts
index dcaa0cd72..96a99b77a 100644
--- a/packages/opencode/src/installation/installation.ts
+++ b/packages/opencode/src/installation/installation.ts
@@ -8,9 +8,9 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
import { Log } from "../util"
-import { CHANNEL as channel, VERSION as version } from "./meta"
import semver from "semver"
+import { InstallationChannel, InstallationVersion } from "./version"
const log = Log.create({ service: "installation" })
@@ -54,16 +54,14 @@ export const Info = z
})
export type Info = z.infer<typeof Info>
-export const VERSION = version
-export const CHANNEL = channel
-export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
+export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
export function isPreview() {
- return CHANNEL !== "latest"
+ return InstallationChannel !== "latest"
}
export function isLocal() {
- return CHANNEL === "local"
+ return InstallationChannel === "local"
}
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
@@ -222,7 +220,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
- const channel = CHANNEL
+ const channel = InstallationChannel
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
@@ -321,7 +319,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
return Service.of({
info: Effect.fn("Installation.info")(function* () {
return {
- version: VERSION,
+ version: InstallationVersion,
latest: yield* latestImpl(),
}
}),
diff --git a/packages/opencode/src/installation/meta.ts b/packages/opencode/src/installation/meta.ts
deleted file mode 100644
index 6a1315db2..000000000
--- a/packages/opencode/src/installation/meta.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-declare global {
- const OPENCODE_VERSION: string
- const OPENCODE_CHANNEL: string
-}
-
-export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
-export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
diff --git a/packages/opencode/src/installation/version.ts b/packages/opencode/src/installation/version.ts
new file mode 100644
index 000000000..f1668d264
--- /dev/null
+++ b/packages/opencode/src/installation/version.ts
@@ -0,0 +1,8 @@
+declare global {
+ const OPENCODE_VERSION: string
+ const OPENCODE_CHANNEL: string
+}
+
+export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
+export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+export const InstallationLocal = InstallationVersion === "local"
diff --git a/packages/opencode/src/mcp/mcp.ts b/packages/opencode/src/mcp/mcp.ts
index 8a57bcff7..1f1022538 100644
--- a/packages/opencode/src/mcp/mcp.ts
+++ b/packages/opencode/src/mcp/mcp.ts
@@ -15,6 +15,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { McpOAuthProvider } from "./oauth-provider"
@@ -265,7 +266,7 @@ export const layer = Layer.effect(
(t) =>
Effect.tryPromise({
try: () => {
- const client = new Client({ name: "opencode", version: Installation.VERSION })
+ const client = new Client({ name: "opencode", version: InstallationVersion })
return withTimeout(client.connect(t), timeout).then(() => client)
},
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
@@ -763,7 +764,7 @@ export const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () => {
- const client = new Client({ name: "opencode", version: Installation.VERSION })
+ const client = new Client({ name: "opencode", version: InstallationVersion })
return client
.connect(transport)
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
diff --git a/packages/opencode/src/npm/npm.ts b/packages/opencode/src/npm/npm.ts
index 7f1744605..d74c10d55 100644
--- a/packages/opencode/src/npm/npm.ts
+++ b/packages/opencode/src/npm/npm.ts
@@ -7,7 +7,6 @@ import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
-import { Arborist } from "@npmcli/arborist"
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
@@ -61,6 +60,7 @@ export async function outdated(pkg: string, cachedVersion: string): Promise<bool
}
export async function add(pkg: string) {
+ const { Arborist } = await import("@npmcli/arborist")
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
@@ -107,6 +107,7 @@ export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
+ const { Arborist } = await import("@npmcli/arborist")
const arb = new Arborist({
path: dir,
binLinks: true,
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index e0f1afa63..c61cb7850 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util"
import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
import { OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
@@ -510,7 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
method: "POST",
headers: {
"Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({ client_id: CLIENT_ID }),
})
@@ -534,7 +535,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
method: "POST",
headers: {
"Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({
device_auth_id: deviceData.device_auth_id,
@@ -594,7 +595,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"chat.headers": async (input, output) => {
if (input.model.providerID !== "openai") return
output.headers.originator = "opencode"
- output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
+ output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers.session_id = input.sessionID
},
"chat.params": async (input, output) => {
diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts
index c1318287c..c9b7e3c1c 100644
--- a/packages/opencode/src/plugin/github-copilot/copilot.ts
+++ b/packages/opencode/src/plugin/github-copilot/copilot.ts
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
import { setTimeout as sleep } from "node:timers/promises"
@@ -70,7 +71,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
base(auth.enterpriseUrl),
{
Authorization: `Bearer ${auth.refresh}`,
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
},
provider.models,
).catch((error) => {
@@ -150,7 +151,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const headers: Record<string, string> = {
"x-initiator": isAgent ? "agent" : "user",
...(init?.headers as Record<string, string>),
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
Authorization: `Bearer ${info.refresh}`,
"Openai-Intent": "conversation-edits",
}
@@ -226,7 +227,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({
client_id: CLIENT_ID,
@@ -256,7 +257,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
},
body: JSON.stringify({
client_id: CLIENT_ID,
diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts
index 8b7e30c40..0525a7ba0 100644
--- a/packages/opencode/src/plugin/install.ts
+++ b/packages/opencode/src/plugin/install.ts
@@ -7,7 +7,7 @@ import {
printParseErrorCode,
} from "jsonc-parser"
-import { ConfigPaths } from "@/config"
+import * as ConfigPaths from "@/config/paths"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts
index 12617f901..0245d311e 100644
--- a/packages/opencode/src/plugin/loader.ts
+++ b/packages/opencode/src/plugin/loader.ts
@@ -1,5 +1,3 @@
-import { Config } from "@/config"
-import { Installation } from "@/installation"
import {
checkPluginCompatibility,
createPluginEntry,
@@ -10,11 +8,13 @@ import {
type PluginPackage,
type PluginSource,
} from "./shared"
+import { ConfigPlugin } from "@/config/plugin"
+import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader {
export type Plan = {
spec: string
- options: Config.PluginOptions | undefined
+ options: ConfigPlugin.Options | undefined
deprecated: boolean
}
export type Resolved = Plan & {
@@ -33,7 +33,7 @@ export namespace PluginLoader {
mod: Record<string, unknown>
}
- type Candidate = { origin: Config.PluginOrigin; plan: Plan }
+ type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
start?: (candidate: Candidate, retry: boolean) => void
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
@@ -46,9 +46,9 @@ export namespace PluginLoader {
) => void
}
- function plan(item: Config.PluginSpec): Plan {
- const spec = Config.pluginSpecifier(item)
- return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
+ function plan(item: ConfigPlugin.Spec): Plan {
+ const spec = ConfigPlugin.pluginSpecifier(item)
+ return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
export async function resolve(
@@ -88,7 +88,7 @@ export namespace PluginLoader {
if (base.source === "npm") {
try {
- await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
+ await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
} catch (error) {
return { ok: false, stage: "compatibility", error }
}
@@ -111,8 +111,8 @@ export namespace PluginLoader {
candidate: Candidate,
kind: PluginKind,
retry: boolean,
- finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
- missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
+ finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
+ missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
@@ -141,11 +141,11 @@ export namespace PluginLoader {
}
type Input<R> = {
- items: Config.PluginOrigin[]
+ items: ConfigPlugin.Origin[]
kind: PluginKind
wait?: () => Promise<void>
- finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
- missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
+ finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
+ missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
report?: Report
}
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index a405607be..e506d2fed 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -26,7 +26,7 @@ export const InstanceBootstrap = Effect.gen(function* () {
Vcs.Service,
Snapshot.Service,
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
- )
+ ).pipe(Effect.withSpan("InstanceBootstrap.init"))
yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 77a45cb1b..43ae9a5e9 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -14,6 +14,7 @@ import * as ModelsDev from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
+import { InstallationVersion } from "../installation/version"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
@@ -24,39 +25,7 @@ import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { isRecord } from "@/util/record"
-// Direct imports for bundled providers
-import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
-import { createAnthropic } from "@ai-sdk/anthropic"
-import { createAzure } from "@ai-sdk/azure"
-import { createGoogleGenerativeAI } from "@ai-sdk/google"
-import { createVertex } from "@ai-sdk/google-vertex"
-import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
-import { createOpenAI } from "@ai-sdk/openai"
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
-import { createOpenRouter } from "@openrouter/ai-sdk-provider"
-import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
-import { createXai } from "@ai-sdk/xai"
-import { createMistral } from "@ai-sdk/mistral"
-import { createGroq } from "@ai-sdk/groq"
-import { createDeepInfra } from "@ai-sdk/deepinfra"
-import { createCerebras } from "@ai-sdk/cerebras"
-import { createCohere } from "@ai-sdk/cohere"
-import { createGateway } from "@ai-sdk/gateway"
-import { createTogetherAI } from "@ai-sdk/togetherai"
-import { createPerplexity } from "@ai-sdk/perplexity"
-import { createVercel } from "@ai-sdk/vercel"
-import { createVenice } from "venice-ai-sdk-provider"
-import { createAlibaba } from "@ai-sdk/alibaba"
-import {
- createGitLab,
- VERSION as GITLAB_PROVIDER_VERSION,
- isWorkflowModel,
- discoverWorkflowModels,
-} from "gitlab-ai-provider"
-import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
-import { GoogleAuth } from "google-auth-library"
import * as ProviderTransform from "./transform"
-import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
const log = Log.create({ service: "provider" })
@@ -119,30 +88,31 @@ type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
-const BUNDLED_PROVIDERS: Record<string, (options: any) => BundledSDK> = {
- "@ai-sdk/amazon-bedrock": createAmazonBedrock,
- "@ai-sdk/anthropic": createAnthropic,
- "@ai-sdk/azure": createAzure,
- "@ai-sdk/google": createGoogleGenerativeAI,
- "@ai-sdk/google-vertex": createVertex,
- "@ai-sdk/google-vertex/anthropic": createVertexAnthropic,
- "@ai-sdk/openai": createOpenAI,
- "@ai-sdk/openai-compatible": createOpenAICompatible,
- "@openrouter/ai-sdk-provider": createOpenRouter,
- "@ai-sdk/xai": createXai,
- "@ai-sdk/mistral": createMistral,
- "@ai-sdk/groq": createGroq,
- "@ai-sdk/deepinfra": createDeepInfra,
- "@ai-sdk/cerebras": createCerebras,
- "@ai-sdk/cohere": createCohere,
- "@ai-sdk/gateway": createGateway,
- "@ai-sdk/togetherai": createTogetherAI,
- "@ai-sdk/perplexity": createPerplexity,
- "@ai-sdk/vercel": createVercel,
- "@ai-sdk/alibaba": createAlibaba,
- "gitlab-ai-provider": createGitLab,
- "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
- "venice-ai-sdk-provider": createVenice,
+const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
+ "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
+ "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
+ "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
+ "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
+ "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
+ "@ai-sdk/google-vertex/anthropic": () =>
+ import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
+ "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
+ "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
+ "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
+ "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
+ "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
+ "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
+ "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
+ "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
+ "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
+ "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
+ "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
+ "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
+ "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
+ "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
+ "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
+ "@ai-sdk/github-copilot": () => import("./sdk/copilot").then((m) => m.createOpenaiCompatible),
+ "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -307,7 +277,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
return { autoload: false }
- const providerOptions: AmazonBedrockProviderSettings = {
+ const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
+
+ const providerOptions: Record<string, any> = {
region: defaultRegion,
}
@@ -465,6 +437,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
project,
location,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const { GoogleAuth } = await import("google-auth-library")
const auth = new GoogleAuth()
const client = await auth.getApplicationDefault()
const token = await client.credential.getAccessToken()
@@ -534,6 +507,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
}),
gitlab: Effect.fnUntraced(function* (input: Info) {
+ const {
+ VERSION: GITLAB_PROVIDER_VERSION,
+ isWorkflowModel,
+ discoverWorkflowModels,
+ } = yield* Effect.promise(() => import("gitlab-ai-provider"))
+
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
const auth = yield* dep.auth(input.id)
@@ -547,7 +526,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
const aiGatewayHeaders = {
- "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+ "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
"anthropic-beta": "context-1m-2025-08-07",
...providerConfig?.options?.aiGatewayHeaders,
}
@@ -566,7 +545,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
aiGatewayHeaders,
featureFlags,
},
- async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
+ async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (modelID.startsWith("duo-workflow-")) {
const workflowRef = options?.workflowRef as string | undefined
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
@@ -701,7 +680,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
options: {
apiKey,
headers: {
- "User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
+ "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
},
},
async getModel(sdk: any, modelID: string) {
@@ -772,7 +751,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
skipCache: input.options?.skipCache,
collectLog: input.options?.collectLog,
headers: {
- "User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
+ "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
},
}
@@ -1454,13 +1433,14 @@ const layer: Layer.Layer<
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
- const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
- if (bundledFn) {
+ const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
+ if (bundledLoader) {
log.info("using bundled provider", {
providerID: model.providerID,
pkg: model.api.npm,
})
- const loaded = bundledFn({
+ const factory = await bundledLoader()
+ const loaded = factory({
name: model.providerID,
...options,
})
diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts
index ac73bb64d..8208cf966 100644
--- a/packages/opencode/src/server/instance/global.ts
+++ b/packages/opencode/src/server/instance/global.ts
@@ -10,6 +10,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import { Log } from "../../util"
import { lazy } from "../../util/lazy"
import { Config } from "../../config"
@@ -89,7 +90,7 @@ export const GlobalRoutes = lazy(() =>
},
}),
async (c) => {
- return c.json({ healthy: true, version: Installation.VERSION })
+ return c.json({ healthy: true, version: InstallationVersion })
},
)
.get(
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 2d1577e7e..d38c29765 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -20,6 +20,7 @@ import { Wildcard } from "@/util"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
import { Installation } from "@/installation"
+import { InstallationVersion } from "@/installation/version"
import { EffectBridge } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
@@ -365,7 +366,7 @@ export namespace LLM {
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
- "User-Agent": `opencode/${Installation.VERSION}`,
+ "User-Agent": `opencode/${InstallationVersion}`,
}),
...input.model.headers,
...headers,
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index 9ebddf8de..e288aec73 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -7,6 +7,7 @@ import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
+import { InstallationVersion } from "../installation/version"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
import { SyncEvent } from "../sync"
@@ -399,7 +400,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
const result: Info = {
id: SessionID.descending(input.id),
slug: Slug.create(),
- version: Installation.VERSION,
+ version: InstallationVersion,
projectID: ctx.project.id,
directory: input.directory,
workspaceID: input.workspaceID,
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
index 1b6b2d9b3..2c0076452 100644
--- a/packages/opencode/src/storage/db.ts
+++ b/packages/opencode/src/storage/db.ts
@@ -11,7 +11,7 @@ import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import { Flag } from "../flag/flag"
-import { CHANNEL } from "../installation/meta"
+import { InstallationChannel } from "../installation/version"
import { InstanceState } from "@/effect"
import { iife } from "@/util/iife"
import { init } from "#db"
@@ -28,9 +28,9 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export function getChannelPath() {
- if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
+ if (["latest", "beta", "prod"].includes(InstallationChannel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
- const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
+ const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
}
diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts
new file mode 100644
index 000000000..bbb97e0f0
--- /dev/null
+++ b/packages/opencode/src/temporary.ts
@@ -0,0 +1,33 @@
+import yargs from "yargs"
+import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { InstallationVersion } from "./installation/version"
+import { hideBin } from "yargs/helpers"
+import { Log } from "./node"
+
+Log.init({
+ print: false,
+})
+
+const cli = yargs(hideBin(process.argv))
+ .parserConfiguration({ "populate--": true })
+ .scriptName("opencode")
+ .wrap(100)
+ .help("help", "show help")
+ .alias("help", "h")
+ .version("version", "show version number", InstallationVersion)
+ .alias("version", "v")
+ .option("print-logs", {
+ describe: "print logs to stderr",
+ type: "boolean",
+ })
+ .option("log-level", {
+ describe: "log level",
+ type: "string",
+ choices: ["DEBUG", "INFO", "WARN", "ERROR"],
+ })
+ .option("pure", {
+ describe: "run without external plugins",
+ type: "boolean",
+ })
+ .command(TuiThreadCommand)
+ .parse()
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index c3f59d329..3ff2c6e3f 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,6 +1,5 @@
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
-import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
import { Readable } from "stream"
@@ -101,7 +100,8 @@ export async function writeStream(
}
}
-export function mimeType(p: string): string {
+export async function mimeType(p: string): Promise<string> {
+ const { lookup } = await import("mime-types")
return lookup(p) || "application/octet-stream"
}
diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts
index 11865bedd..972da0f50 100644
--- a/packages/opencode/test/cli/tui/plugin-add.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-add.test.ts
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -31,15 +31,18 @@ test("adds tui plugin at runtime from spec", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({
+ api: createTuiPluginApi(),
+ config,
+ })
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -72,10 +74,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
await Bun.write(
path.join(tmp.extra.mod, "index.ts"),
@@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({
+ api: createTuiPluginApi(),
+ config,
+ })
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts
index bd490ac4f..ca7e8fcd2 100644
--- a/packages/opencode/test/cli/tui/plugin-install.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-install.test.ts
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -50,11 +50,10 @@ test("installs plugin without loading it", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
+ const config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
}
- const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({
@@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => {
})
try {
- await TuiPluginRuntime.init(api)
+ await TuiPluginRuntime.init({ api, config })
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
expect(out).toMatchObject({
ok: true,
@@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
index 078e4484d..8725fe8b9 100644
--- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
@@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async ()
},
})
- const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
+ const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.dispose()
const marker = await fs.readFile(tmp.extra.marker, "utf8")
@@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => {
},
})
- const restore = mockTuiRuntime(tmp.path, [
+ const { config, restore } = mockTuiRuntime(tmp.path, [
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
])
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin's onDispose ran during rollback
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
// good plugin still loaded
@@ -155,11 +155,11 @@ export default {
},
})
- const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+ const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const err = spyOn(console, "error").mockImplementation(() => {})
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("one")
@@ -202,10 +202,10 @@ test(
},
})
- const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
+ const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 7000)
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
index 7020ac742..395e8ce42 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Npm } from "../../../src/npm"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
expect(hit?.enabled).toBe(true)
@@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -106,7 +105,7 @@ test("does not use npm package exports dot for tui entry", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -169,7 +167,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// plugin code never ran
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
// plugin not listed
@@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -232,7 +229,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -291,7 +287,7 @@ test("does not use npm package main for tui entry", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
@@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
expect(error).not.toHaveBeenCalled()
@@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
warn.mockRestore()
error.mockRestore()
@@ -357,7 +352,7 @@ test("does not use directory package main for tui entry", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -405,7 +399,7 @@ test("uses directory index fallback for tui when package.json is missing", async
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -463,7 +456,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
index 25233adaa..ba7a4b395 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => {
process.env.OPENCODE_PURE = "1"
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
if (pure === undefined) {
delete process.env.OPENCODE_PURE
diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts
index 4dc2aeccd..dc64fb336 100644
--- a/packages/opencode/test/cli/tui/plugin-loader.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts
@@ -5,8 +5,8 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global"
-import { TuiConfig } from "../../../src/config"
-import { Filesystem } from "../../../src/util"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { Filesystem } from "../../../src/util/"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -328,8 +328,55 @@ export default {
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
- await TuiPluginRuntime.init(
- createTuiPluginApi({
+ const localOpts = {
+ fn_marker: tmp.extra.fnMarker,
+ marker: tmp.extra.localMarker,
+ source: tmp.extra.localDest.replace(".opencode/themes/", ""),
+ dest: tmp.extra.localDest,
+ theme_path: `./${tmp.extra.localThemeFile}`,
+ theme_name: tmp.extra.localThemeName,
+ kv_key: "plugin_state_key",
+ session_id: "ses_test",
+ keybinds: { modal: "ctrl+alt+m", close: "q" },
+ }
+ const invalidOpts = {
+ marker: tmp.extra.invalidMarker,
+ theme_path: `./${tmp.extra.invalidThemeFile}`,
+ theme_name: tmp.extra.invalidThemeName,
+ }
+ const preloadedOpts = {
+ marker: tmp.extra.preloadedMarker,
+ dest: tmp.extra.preloadedDest,
+ theme_path: `./${tmp.extra.preloadedThemeFile}`,
+ theme_name: tmp.extra.preloadedThemeName,
+ }
+ const globalOpts = {
+ marker: tmp.extra.globalMarker,
+ theme_path: `./${tmp.extra.globalThemeFile}`,
+ theme_name: tmp.extra.globalThemeName,
+ }
+
+ const config: TuiConfig.Info = {
+ plugin: [
+ [tmp.extra.localSpec, localOpts],
+ [tmp.extra.invalidSpec, invalidOpts],
+ [tmp.extra.preloadedSpec, preloadedOpts],
+ [tmp.extra.globalSpec, globalOpts],
+ ],
+ plugin_origins: [
+ { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+ { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+ { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
+ {
+ spec: [tmp.extra.globalSpec, globalOpts],
+ scope: "global",
+ source: path.join(Global.Path.config, "tui.json"),
+ },
+ ],
+ }
+
+ await TuiPluginRuntime.init({
+ api: createTuiPluginApi({
tuiConfig: {
theme: "smoke",
diff_style: "stacked",
@@ -366,7 +413,8 @@ export default {
},
},
}),
- )
+ config,
+ })
const local = await row(tmp.extra.localMarker)
const global = await row(tmp.extra.globalMarker)
const invalid = await row(tmp.extra.invalidMarker)
@@ -459,7 +507,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin was skipped (no metadata entry)
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
// good plugin loaded fine
@@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -555,7 +602,18 @@ export default {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
- await TuiPluginRuntime.init(createTuiPluginApi())
+ const a = path.join(tmp.path, "order-a.ts")
+ const b = path.join(tmp.path, "order-b.ts")
+ const aSpec = pathToFileURL(a).href
+ const bSpec = pathToFileURL(b).href
+ const config: TuiConfig.Info = {
+ plugin: [aSpec, bSpec],
+ plugin_origins: [
+ { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
+ { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
+ ],
+ }
+ await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"])
} finally {
@@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const api = () =>
+ const mkApi = () =>
createTuiPluginApi({
theme: {
has(name) {
@@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => {
},
})
+ const mkConfig = (): TuiConfig.Info => ({
+ plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
+ plugin_origins: [
+ {
+ spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ ],
+ })
+
try {
- await TuiPluginRuntime.init(api())
+ await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
await TuiPluginRuntime.dispose()
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
@@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => {
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
await fs.utimes(tmp.extra.themePath, stamp, stamp)
- await TuiPluginRuntime.init(api())
+ await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
const text = await fs.readFile(tmp.extra.dest, "utf8")
expect(text).toContain("#222222")
expect(text).not.toContain("#111111")
diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
index 3f04e3c6f..11fdf5ce4 100644
--- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts
@@ -4,7 +4,7 @@ import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
-import { TuiConfig } from "../../../src/config"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.toggle": false,
@@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
- await TuiPluginRuntime.init(api)
+ await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
@@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -117,7 +116,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
+ const config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.startup": false,
@@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
- })
+ }
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
@@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
})
try {
- await TuiPluginRuntime.init(api)
+ await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
@@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
- get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts
index 7b781c49e..40f4021a2 100644
--- a/packages/opencode/test/cli/tui/thread.test.ts
+++ b/packages/opencode/test/cli/tui/thread.test.ts
@@ -8,13 +8,11 @@ import { UI } from "../../../src/cli/ui"
import * as Timeout from "../../../src/util/timeout"
import * as Network from "../../../src/cli/network"
import * as Win32 from "../../../src/cli/cmd/tui/win32"
-import { TuiConfig } from "../../../src/config"
-import { Instance } from "../../../src/project/instance"
+import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const stop = new Error("stop")
const seen = {
tui: [] as string[],
- inst: [] as string[],
}
function setup() {
@@ -42,11 +40,6 @@ function setup() {
})
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
- spyOn(TuiConfig, "get").mockResolvedValue({})
- spyOn(Instance, "provide").mockImplementation(async (input) => {
- seen.inst.push(input.directory)
- return input.fn()
- })
}
describe("tui thread", () => {
@@ -86,7 +79,6 @@ describe("tui thread", () => {
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0
- seen.inst.length = 0
await fs.symlink(tmp.path, link, type)
Object.defineProperty(process.stdin, "isTTY", {
@@ -105,7 +97,6 @@ describe("tui thread", () => {
process.chdir(tmp.path)
process.env.PWD = link
await expect(call(project)).rejects.toBe(stop)
- expect(seen.inst[0]).toBe(tmp.path)
expect(seen.tui[0]).toBe(tmp.path)
} finally {
process.chdir(cwd)
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index e309416f1..92c919dc2 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util"
import * as Network from "../../src/util/network"
import { Npm } from "../../src/npm"
+import { ConfigPlugin } from "@/config/plugin"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
const cfg = await load()
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
- const names = plugins.map((item) => Config.pluginSpecifier(item))
+ const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
expect(names).toContain("[email protected]")
expect(names).not.toContain("[email protected]")
@@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
expect(names).toContain("[email protected]")
expect(origins.map((item) => item.spec)).toEqual(plugins)
- const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "[email protected]")
+ const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "[email protected]")
expect(hit?.scope).toBe("local")
},
})
@@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => {
test("keeps package specs unchanged", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "opencode.json")
- expect(await Config.resolvePluginSpec("[email protected]", file)).toBe("[email protected]")
- expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
+ expect(await ConfigPlugin.resolvePluginSpec("[email protected]", file)).toBe("[email protected]")
+ expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
})
test("resolves windows-style relative plugin directory specs", async () => {
@@ -1925,8 +1926,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
- const hit = await Config.resolvePluginSpec(".\\plugin", file)
- expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
+ const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
+ expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
})
test("resolves relative file plugin paths to file urls", async () => {
@@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
- const hit = await Config.resolvePluginSpec("./plugin.ts", file)
- expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
+ const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
+ expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
})
test("resolves plugin directory paths to directory urls", async () => {
@@ -1956,8 +1957,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
- const hit = await Config.resolvePluginSpec("./plugin", file)
- expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
+ const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
+ expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
})
test("resolves plugin directories without package.json to index.ts", async () => {
diff --git a/packages/opencode/test/config/plugin.test.ts b/packages/opencode/test/config/plugin.test.ts
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/opencode/test/config/plugin.test.ts
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
index 62587d270..c7b6d4a50 100644
--- a/packages/opencode/test/config/tui.test.ts
+++ b/packages/opencode/test/config/tui.test.ts
@@ -3,13 +3,15 @@ import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
+import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Config } from "../../src/config"
-import { TuiConfig } from "../../src/config"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util"
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 managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
@@ -18,6 +20,13 @@ 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)))),
+ ),
+ )
+
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
@@ -25,7 +34,6 @@ afterEach(async () => {
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 fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await clear(true)
})
@@ -83,9 +91,9 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
directory: tmp.path,
fn: async () => {
const server = await load()
- const tui = await TuiConfig.get()
- const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
- const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
+ 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("[email protected]")
@@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
const serverOrigins = server.plugin_origins ?? []
const tuiOrigins = tui.plugin_origins ?? []
- expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
- expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
+ 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))
},
})
@@ -113,14 +121,9 @@ test("loads tui config with the same precedence order as server config paths", a
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("local")
- expect(config.diff_style).toBe("stacked")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.theme).toBe("local")
+ expect(config.diff_style).toBe("stacked")
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
@@ -141,26 +144,21 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("migrated-theme")
- expect(config.scroll_speed).toBe(5)
- expect(config.keybinds?.app_exit).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 = await getTuiConfig(tmp.path)
+ expect(config.theme).toBe("migrated-theme")
+ expect(config.scroll_speed).toBe(5)
+ expect(config.keybinds?.app_exit).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 () => {
@@ -181,19 +179,14 @@ test("migrates project legacy tui keys even when global tui.json already exists"
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- 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()
- },
- })
+ 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 () => {
@@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- 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 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 () => {
@@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () =>
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- 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 = 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 () => {
@@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.diff_style).toBe("stacked")
- expect(config.theme).toBeUndefined()
+ 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)
- },
- })
+ 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 () => {
@@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async
await fs.chmod(source, 0o444)
try {
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- 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")
- },
- })
+ 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)
}
@@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- await TuiConfig.get()
- 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')
- },
- })
+ 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 () => {
@@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () =
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
},
})
-
- await Instance.provide({
- directory: path.join(tmp.path, "apps", "client"),
- fn: async () => {
- const config = await TuiConfig.get()
- 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)
- },
- })
+ 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 () => {
@@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- 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")
- },
- })
+ 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 () => {
@@ -395,14 +352,9 @@ test("top-level keys in tui.json take precedence over nested tui key", async ()
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.diff_style).toBe("auto")
- expect(config.scroll_speed).toBe(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 () => {
@@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- // 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")
- },
- })
+ 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 () => {
@@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.keybinds?.app_exit).toBe("ctrl+q")
- expect(config.keybinds?.theme_list).toBe("ctrl+k")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.keybinds?.app_exit).toBe("ctrl+q")
+ expect(config.keybinds?.theme_list).toBe("ctrl+k")
})
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir()
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.keybinds?.terminal_suspend).toBe("none")
- expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.keybinds?.terminal_suspend).toBe("none")
+ expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
})
wintest("keeps explicit input undo overrides on Windows", async () => {
@@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.keybinds?.terminal_suspend).toBe("none")
- expect(config.keybinds?.input_undo).toBe("ctrl+y")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.keybinds?.terminal_suspend).toBe("none")
+ expect(config.keybinds?.input_undo).toBe("ctrl+y")
})
wintest("ignores terminal suspend bindings on Windows", async () => {
@@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.keybinds?.terminal_suspend).toBe("none")
- expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.keybinds?.terminal_suspend).toBe("none")
+ expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
@@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn
process.env.OPENCODE_TUI_CONFIG = custom
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("from-env")
- expect(config.diff_style).toBe("stacked")
- },
- })
+ 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 () => {
@@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => {
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBeUndefined()
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.theme).toBeUndefined()
})
test("applies env and file substitutions in tui.json", async () => {
@@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => {
)
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("env-theme")
- expect(config.keybinds?.app_exit).toBe("ctrl+q")
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.theme).toBe("env-theme")
+ expect(config.keybinds?.app_exit).toBe("ctrl+q")
} finally {
if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original
@@ -575,46 +481,8 @@ test("applies file substitutions when first identical token is in a commented li
)
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("resolved-theme")
- },
- })
-})
-
-test("loads managed tui config and gives it highest precedence", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(
- path.join(dir, "tui.json"),
- JSON.stringify({ theme: "project-theme", plugin: ["[email protected]"] }, null, 2),
- )
- await fs.mkdir(managedConfigDir, { recursive: true })
- await Bun.write(
- path.join(managedConfigDir, "tui.json"),
- JSON.stringify({ theme: "managed-theme", plugin: ["[email protected]"] }, null, 2),
- )
- },
- })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("managed-theme")
- expect(config.plugin).toEqual(["[email protected]"])
- expect(config.plugin_origins).toEqual([
- {
- scope: "global",
- source: path.join(managedConfigDir, "tui.json"),
- },
- ])
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.theme).toBe("resolved-theme")
})
test("loads .opencode/tui.json", async () => {
@@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => {
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.diff_style).toBe("stacked")
- },
- })
-})
-
-test("gracefully falls back when tui.json has invalid JSON", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
- await fs.mkdir(managedConfigDir, { recursive: true })
- await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
- },
- })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.theme).toBe("managed-fallback")
- expect(config.keybinds).toBeDefined()
- },
- })
+ const config = await getTuiConfig(tmp.path)
+ expect(config.diff_style).toBe("stacked")
})
test("supports tuple plugin specs with options in tui.json", async () => {
@@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
- expect(config.plugin_origins).toEqual([
- {
- spec: ["[email protected]", { enabled: true, label: "demo" }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ])
+ const config = await getTuiConfig(tmp.path)
+ expect(config.plugin).toEqual([["[email protected]", { enabled: true, label: "demo" }]])
+ expect(config.plugin_origins).toEqual([
+ {
+ spec: ["[email protected]", { 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 () => {
@@ -702,28 +540,23 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.plugin).toEqual([
- ["[email protected]", { source: "project" }],
- ["[email protected]", { source: "project" }],
- ])
- expect(config.plugin_origins).toEqual([
- {
- spec: ["[email protected]", { source: "project" }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- {
- spec: ["[email protected]", { source: "project" }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ])
+ const config = await getTuiConfig(tmp.path)
+ expect(config.plugin).toEqual([
+ ["[email protected]", { source: "project" }],
+ ["[email protected]", { source: "project" }],
+ ])
+ expect(config.plugin_origins).toEqual([
+ {
+ spec: ["[email protected]", { source: "project" }],
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
},
- })
+ {
+ spec: ["[email protected]", { source: "project" }],
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ ])
})
test("tracks global and local plugin metadata in merged tui config", async () => {
@@ -744,25 +577,20 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
- expect(config.plugin_origins).toEqual([
- {
- scope: "global",
- source: path.join(Global.Path.config, "tui.json"),
- },
- {
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ])
+ const config = await getTuiConfig(tmp.path)
+ expect(config.plugin).toEqual(["[email protected]", "[email protected]"])
+ expect(config.plugin_origins).toEqual([
+ {
+ scope: "global",
+ source: path.join(Global.Path.config, "tui.json"),
},
- })
+ {
+ scope: "local",
+ source: path.join(tmp.path, "tui.json"),
+ },
+ ])
})
test("merges plugin_enabled flags across config layers", async () => {
@@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => {
},
})
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await TuiConfig.get()
- expect(config.plugin_enabled).toEqual({
- "internal:sidebar-context": false,
- "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,
})
})
diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts
index 28fd2c838..21dbc75b9 100644
--- a/packages/opencode/test/file/index.test.ts
+++ b/packages/opencode/test/file/index.test.ts
@@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- expect(Filesystem.mimeType(filepath)).toContain("application/json")
+ expect(await Filesystem.mimeType(filepath)).toContain("application/json")
const result = await read("test.json")
expect(result.type).toBe("text")
@@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- expect(Filesystem.mimeType(filepath)).toContain(mime)
+ expect(await Filesystem.mimeType(filepath)).toContain(mime)
},
})
}
diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts
index 493b23f7e..ba8099fcd 100644
--- a/packages/opencode/test/fixture/tui-runtime.ts
+++ b/packages/opencode/test/fixture/tui-runtime.ts
@@ -1,27 +1,31 @@
import { spyOn } from "bun:test"
import path from "path"
-import { TuiConfig } from "../../src/config"
+import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
type PluginSpec = string | [string, Record<string, unknown>]
-export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
+export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
const plugin_origins = plugin.map((spec) => ({
spec,
scope: "local" as const,
source: path.join(dir, "tui.json"),
}))
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin,
- plugin_origins,
- })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
- return () => {
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.OPENCODE_PLUGIN_META_FILE
+ const config: TuiConfig.Info = {
+ plugin,
+ plugin_origins,
+ ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
+ }
+
+ return {
+ config,
+ restore: () => {
+ cwd.mockRestore()
+ wait.mockRestore()
+ delete process.env.OPENCODE_PLUGIN_META_FILE
+ },
}
}
diff --git a/packages/opencode/test/storage/db.test.ts b/packages/opencode/test/storage/db.test.ts
index 7edc862c4..6beb95ac5 100644
--- a/packages/opencode/test/storage/db.test.ts
+++ b/packages/opencode/test/storage/db.test.ts
@@ -1,14 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "../../src/global"
-import { Installation } from "../../src/installation"
+import { InstallationChannel } from "../../src/installation/version"
import { Database } from "../../src/storage"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
- const expected = ["latest", "beta"].includes(Installation.CHANNEL)
+ const expected = ["latest", "beta"].includes(InstallationChannel)
? path.join(Global.Path.data, "opencode.db")
- : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
+ : path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.getChannelPath()).toBe(expected)
})
})
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 8e1724b47..3b32c72e0 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -16,6 +16,7 @@ import { Tool } from "../../src/tool"
import { Filesystem } from "../../src/util"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
+import { Npm } from "@opencode-ai/shared/npm"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index 1f3a66b95..d5f8a529b 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -347,31 +347,31 @@ describe("filesystem", () => {
})
describe("mimeType()", () => {
- test("returns correct MIME type for JSON", () => {
- expect(Filesystem.mimeType("test.json")).toContain("application/json")
+ test("returns correct MIME type for JSON", async () => {
+ expect(await Filesystem.mimeType("test.json")).toContain("application/json")
})
- test("returns correct MIME type for JavaScript", () => {
- expect(Filesystem.mimeType("test.js")).toContain("javascript")
+ test("returns correct MIME type for JavaScript", async () => {
+ expect(await Filesystem.mimeType("test.js")).toContain("javascript")
})
- test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
- const mime = Filesystem.mimeType("test.ts")
+ test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => {
+ const mime = await Filesystem.mimeType("test.ts")
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
})
- test("returns correct MIME type for images", () => {
- expect(Filesystem.mimeType("test.png")).toContain("image/png")
- expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
+ test("returns correct MIME type for images", async () => {
+ expect(await Filesystem.mimeType("test.png")).toContain("image/png")
+ expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
})
- test("returns default for unknown extension", () => {
- expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
+ test("returns default for unknown extension", async () => {
+ expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
})
- test("handles files without extension", () => {
- expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
+ test("handles files without extension", async () => {
+ expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
})
})
diff --git a/packages/opencode/time.ts b/packages/opencode/time.ts
new file mode 100755
index 000000000..c00936db2
--- /dev/null
+++ b/packages/opencode/time.ts
@@ -0,0 +1,4 @@
+import path from "path"
+const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
+await import(toDynamicallyImport)
+console.log(performance.now())
diff --git a/packages/opencode/trace-imports.ts b/packages/opencode/trace-imports.ts
new file mode 100755
index 000000000..3aad33851
--- /dev/null
+++ b/packages/opencode/trace-imports.ts
@@ -0,0 +1,153 @@
+#!/usr/bin/env bun
+import * as path from "path"
+import * as ts from "typescript"
+
+const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
+
+// Get entry file from command line arg or use default
+const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
+
+const visited = new Set<string>()
+
+function resolveImport(importPath: string, fromFile: string): string | null {
+ if (importPath.startsWith("@/")) {
+ return path.join(BASE_DIR, "src", importPath.slice(2))
+ }
+
+ if (importPath.startsWith("./") || importPath.startsWith("../")) {
+ const dir = path.dirname(fromFile)
+ return path.resolve(dir, importPath)
+ }
+
+ return null
+}
+
+function isInternalImport(importPath: string): boolean {
+ return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
+}
+
+async function tryExtensions(filePath: string): Promise<string | null> {
+ const extensions = [".ts", ".tsx", ".js", ".jsx"]
+
+ try {
+ const file = Bun.file(filePath)
+ const stat = await file.stat()
+
+ if (stat?.isDirectory()) {
+ for (const ext of extensions) {
+ const indexPath = path.join(filePath, "index" + ext)
+ const indexFile = Bun.file(indexPath)
+ if (await indexFile.exists()) return indexPath
+ }
+ return null
+ }
+
+ // It's a file
+ return filePath
+ } catch {
+ // Path doesn't exist, try adding extensions
+ for (const ext of extensions) {
+ const withExt = filePath + ext
+ const extFile = Bun.file(withExt)
+ if (await extFile.exists()) return withExt
+ }
+ return null
+ }
+}
+
+function extractImports(sourceFile: ts.SourceFile): string[] {
+ const imports: string[] = []
+
+ function visit(node: ts.Node) {
+ // import x from "path" or import { x } from "path"
+ if (ts.isImportDeclaration(node)) {
+ // Skip type-only imports
+ if (node.importClause?.isTypeOnly) return
+
+ const moduleSpec = node.moduleSpecifier
+ if (ts.isStringLiteral(moduleSpec)) {
+ imports.push(moduleSpec.text)
+ }
+ }
+
+ // export { x } from "path"
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
+ if (ts.isStringLiteral(node.moduleSpecifier)) {
+ imports.push(node.moduleSpecifier.text)
+ }
+ }
+
+ // Dynamic import: import("path")
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
+ const arg = node.arguments[0]
+ if (arg && ts.isStringLiteral(arg)) {
+ imports.push(arg.text)
+ }
+ }
+
+ ts.forEachChild(node, visit)
+ }
+
+ visit(sourceFile)
+ return imports
+}
+
+async function traceFile(filePath: string, depth = 0): Promise<void> {
+ const normalizedPath = path.relative(BASE_DIR, filePath)
+
+ if (visited.has(filePath)) {
+ return
+ }
+
+ // Only trace TypeScript/JavaScript files
+ if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
+ return
+ }
+
+ visited.add(filePath)
+ console.log("\t".repeat(depth) + normalizedPath)
+
+ let content: string
+ try {
+ content = await Bun.file(filePath).text()
+ } catch {
+ return
+ }
+
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
+
+ const imports = extractImports(sourceFile)
+ const internalImports = imports.filter(isInternalImport)
+ const externalImports = imports.filter((imp) => !isInternalImport(imp))
+
+ // Print external imports
+ for (const imp of externalImports) {
+ console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
+ }
+
+ for (const imp of internalImports) {
+ const resolved = resolveImport(imp, filePath)
+ if (!resolved) continue
+
+ const actualPath = await tryExtensions(resolved)
+ if (!actualPath) continue
+
+ await traceFile(actualPath, depth + 1)
+ }
+}
+
+async function main() {
+ const entryPath = path.join(BASE_DIR, ENTRY_FILE)
+
+ // Check if file exists
+ const file = Bun.file(entryPath)
+ if (!(await file.exists())) {
+ console.error(`File not found: ${ENTRY_FILE}`)
+ console.error(`Resolved to: ${entryPath}`)
+ process.exit(1)
+ }
+
+ await traceFile(entryPath)
+}
+
+main().catch(console.error)
diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json
index ff9886313..5cb51012a 100644
--- a/packages/opencode/tsconfig.json
+++ b/packages/opencode/tsconfig.json
@@ -10,7 +10,8 @@
"customConditions": ["browser"],
"paths": {
"@/*": ["./src/*"],
- "@tui/*": ["./src/cli/cmd/tui/*"]
+ "@tui/*": ["./src/cli/cmd/tui/*"],
+ "@test/*": ["./test/*"]
},
"plugins": [
{
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 252b381d4..ac2d8f209 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -6,7 +6,8 @@
"license": "MIT",
"private": true,
"scripts": {
- "test": "bun test"
+ "test": "bun test",
+ "typecheck": "tsgo --noEmit"
},
"bin": {
"opencode": "./bin/opencode"
@@ -17,7 +18,9 @@
"imports": {},
"devDependencies": {
"@types/semver": "catalog:",
- "@types/bun": "catalog:"
+ "@types/bun": "catalog:",
+ "@types/npmcli__arborist": "6.3.3",
+ "@tsconfig/bun": "catalog:"
},
"dependencies": {
"@effect/platform-node": "catalog:",
diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts
index 8bd0cc468..955cafa19 100644
--- a/packages/shared/src/npm.ts
+++ b/packages/shared/src/npm.ts
@@ -1,6 +1,5 @@
import path from "path"
import semver from "semver"
-import { Arborist } from "@npmcli/arborist"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem"
@@ -19,8 +18,8 @@ export namespace Npm {
}
export interface Interface {
- readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError>
- readonly install: (dir: string) => Effect.Effect<void>
+ readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
+ readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect<void, EffectFlock.LockError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
@@ -92,6 +91,7 @@ export namespace Npm {
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
+ const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const dir = directory(pkg)
yield* flock.acquire(`npm-install:${dir}`)
@@ -133,10 +133,17 @@ export namespace Npm {
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
- const install = Effect.fn("Npm.install")(function* (dir: string) {
+ const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
+ const canWrite = yield* afs.access(dir, { writable: true }).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ )
+ if (!canWrite) return
+
yield* flock.acquire(`npm-install:${dir}`)
const reify = Effect.fnUntraced(function* () {
+ const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arb = new Arborist({
path: dir,
binLinks: true,
@@ -145,7 +152,14 @@ export namespace Npm {
ignoreScripts: true,
})
yield* Effect.tryPromise({
- try: () => arb.reify().catch(() => {}),
+ try: () =>
+ arb
+ .reify({
+ add: input?.add || [],
+ save: true,
+ saveType: "prod",
+ })
+ .catch(() => {}),
catch: () => {},
}).pipe(Effect.orElseSucceed(() => {}))
})
@@ -167,6 +181,7 @@ export namespace Npm {
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
+ ...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {}
diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts
index 12c27a0a7..9d3b7c661 100644
--- a/packages/shared/src/util/error.ts
+++ b/packages/shared/src/util/error.ts
@@ -4,6 +4,12 @@ export abstract class NamedError extends Error {
abstract schema(): z.core.$ZodType
abstract toObject(): { name: string; data: any }
+ static hasName(error: unknown, name: string): boolean {
+ return (
+ typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
+ )
+ }
+
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
const schema = z
.object({
diff --git a/packages/shared/src/util/flock.ts b/packages/shared/src/util/flock.ts
index 4a1df1dee..958bd9fd1 100644
--- a/packages/shared/src/util/flock.ts
+++ b/packages/shared/src/util/flock.ts
@@ -345,10 +345,14 @@ export namespace Flock {
return await fn()
}
- export const effect = Effect.fn("Flock.effect")(function* (key: string) {
+ export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) {
return yield* Effect.acquireRelease(
- Effect.promise((signal) => Flock.acquire(key, { signal })),
- (foo) => Effect.promise(() => foo.release()),
+ Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe(
+ Effect.withSpan("Flock.acquire", {
+ attributes: { key },
+ }),
+ ),
+ (lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")),
).pipe(Effect.asVoid)
})
}
diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json
index ff9886313..d7745d755 100644
--- a/packages/shared/tsconfig.json
+++ b/packages/shared/tsconfig.json
@@ -2,16 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
- "jsx": "preserve",
- "jsxImportSource": "@opentui/solid",
- "lib": ["ESNext", "DOM", "DOM.Iterable"],
- "types": [],
"noUncheckedIndexedAccess": false,
- "customConditions": ["browser"],
- "paths": {
- "@/*": ["./src/*"],
- "@tui/*": ["./src/cli/cmd/tui/*"]
- },
"plugins": [
{
"name": "@effect/language-service",