Compare commits

...

13 Commits

Author SHA1 Message Date
Dax Raad
2b484d54c9 sync 2026-04-15 21:56:43 -04:00
Dax Raad
3005b01f03 sync 2026-04-15 21:28:07 -04:00
Dax Raad
611dfc6c58 optimizations 2026-04-15 20:29:47 -04:00
Dax Raad
a5908f214b sync 2026-04-15 18:46:15 -04:00
Dax Raad
22c761dca5 core: remove redundant forkDetach from file cache scanning and properly fork ensure during init 2026-04-15 18:01:49 -04:00
Dax Raad
02796b163c core: improve startup observability and reduce initialization blocking
- Add tracing spans to instance bootstrap initialization for easier debugging of startup issues
- Make git branch discovery non-blocking so workspace loads faster without waiting for VCS state
2026-04-15 17:51:15 -04:00
Dax Raad
ba34400ad7 performance progress 2026-04-15 17:39:25 -04:00
Dax Raad
30b9f473b5 tui: pass config directly to plugin runtime to eliminate instance dependency for more reliable plugin loading 2026-04-15 17:39:25 -04:00
Dax Raad
061f76eaad tui: make TUI config loading independent of global instance state 2026-04-15 17:39:25 -04:00
Dax Raad
2ff61641a1 core: fix TuiConfig to use InstanceState for proper context-aware config loading 2026-04-15 17:39:25 -04:00
Dax Raad
a9713a864e core: move TUI config files from test to src directory
The TUI config files (tui.ts, tui-schema.ts, tui-migrate.ts, console-state.ts) are
source files used by the application, not test files. Moved them to the proper
location in src/cli/cmd/tui/config/ and updated all imports.
2026-04-15 17:39:25 -04:00
Dax Raad
fa8aa0b7e2 core: refactor plugin dependency installation to use shared npm service
Moved Npm service to shared package to enable consistent dependency management

across all components. Plugin dependencies are now installed with proper

version targeting and concurrent install deduplication via Flock.

Changes from user perspective:

- Faster TUI startup by removing unnecessary Instance.provide wrapper

- More reliable plugin dependency installation with proper serialization

- Cleaner config loading without redundant worktree parameter requirements
2026-04-15 17:39:24 -04:00
Dax Raad
0b9c05eb12 sync 2026-04-15 17:39:08 -04:00
55 changed files with 1045 additions and 1067 deletions

View File

@@ -534,6 +534,7 @@
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
},
},

View File

@@ -2,7 +2,7 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {

View File

@@ -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/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
@@ -234,7 +234,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)
})

View File

@@ -2,9 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Effect } from "effect"
import { CliLayer } from "./layer"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -66,10 +66,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,

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -599,6 +599,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") {
exit()
@@ -659,7 +661,7 @@ export function Prompt(props: PromptProps) {
if (store.mode === "shell") {
sdk.client.session.shell({
sessionID,
agent: local.agent.current().name,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
@@ -686,7 +688,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,
@@ -703,7 +705,7 @@ export function Prompt(props: PromptProps) {
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
agent: agent.name,
model: selectedModel,
variant,
parts: [
@@ -826,7 +828,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(() => {
@@ -848,7 +852,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,
@@ -1104,22 +1109,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()}>

View File

@@ -0,0 +1,5 @@
import { Context } from "effect"
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
defaultValue: () => process.cwd(),
})

View File

@@ -2,9 +2,8 @@ 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 { ConfigPaths } from "./paths"
import { ConfigPaths } from "@/config/paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
@@ -26,9 +25,9 @@ const TuiLegacy = z
.strip()
interface MigrateInput {
cwd: string
directories: string[]
custom?: string
managed: string
}
/**
@@ -134,16 +133,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) => {

View File

@@ -1,9 +1,10 @@
import z from "zod"
import { 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)

View File

@@ -1,9 +1,7 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { ConfigPaths } from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Flag } from "@/flag/flag"
@@ -11,9 +9,13 @@ import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { Installation } from "@/installation"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@@ -31,7 +33,7 @@ export namespace TuiConfig {
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: Config.PluginOrigin[]
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
@@ -41,9 +43,9 @@ export namespace TuiConfig {
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
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"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
@@ -67,13 +69,13 @@ export namespace TuiConfig {
}
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
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 = Config.deduplicatePluginOrigins([
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
@@ -81,18 +83,13 @@ export namespace TuiConfig {
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)
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()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
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, ctx.worktree)
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
@@ -120,21 +117,16 @@ export namespace TuiConfig {
}
}
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(
",",
)
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = Config.Keybinds.parse(keybinds)
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
@@ -145,41 +137,43 @@ export namespace TuiConfig {
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 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" + (Installation.isLocal() ? "" : "@" + Installation.VERSION)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
),
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}),
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
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 {}
@@ -206,7 +200,7 @@ export namespace TuiConfig {
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)
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
}
}

View File

@@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/config/tui"
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"

View File

@@ -37,10 +37,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 +55,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))
@@ -194,8 +192,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 +238,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 +266,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 +287,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 +391,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({

View File

@@ -29,7 +29,11 @@ import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
import {
ConsoleState,
emptyConsoleState,
type ConsoleState as ConsoleStateType,
} from "@/cli/cmd/tui/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -378,7 +382,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
@@ -463,7 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return store.status !== "loading"
return true
},
get path() {
return project.instance.path()

View File

@@ -1,4 +1,4 @@
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({

View File

@@ -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))

View File

@@ -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/tui"
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"
@@ -19,7 +19,6 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { Installation } from "@/installation"
import { type OpencodeClient } from "@opencode-ai/sdk/v2"
type RouteEntry = {
key: symbol

View File

@@ -12,13 +12,10 @@ import {
} from "@opencode-ai/plugin/tui"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { TuiConfig } from "@/config/tui"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util/log"
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)
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) => {
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 })
})
}
}
}

View File

@@ -8,14 +8,13 @@ import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
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/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
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

View File

@@ -1,5 +1,5 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
import type { TuiConfig } from "@/config/tui"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}

View File

@@ -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")

View File

@@ -30,28 +30,16 @@ import { Glob } from "@opencode-ai/shared/util/glob"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import type { ConsoleState } from "./console-state"
import type { ConsoleState } from "@/cli/cmd/tui/config/console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@opencode-ai/shared/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "../npm"
import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm"
import { ConfigPlugin } from "./plugin"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown())
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
export type PluginScope = "global" | "local"
export type PluginOrigin = {
spec: PluginSpec
source: string
scope: PluginScope
}
const log = Log.create({ service: "config" })
@@ -140,10 +128,6 @@ export namespace Config {
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
}
type Package = {
dependencies?: Record<string, string>
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -271,60 +255,6 @@ export namespace Config {
return result
}
async function loadPlugin(dir: string) {
const plugins: PluginSpec[] = []
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: PluginSpec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
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: PluginOrigin[]): PluginOrigin[] {
const seen = new Set<string>()
const list: PluginOrigin[] = []
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()
}
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
@@ -566,167 +496,6 @@ export namespace Config {
})
export type Agent = z.infer<typeof Agent>
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",
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -883,7 +652,7 @@ export namespace Config {
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
plugin: PluginSpec.array().optional(),
plugin: ConfigPlugin.Spec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -1060,7 +829,7 @@ export namespace Config {
})
export type Info = z.output<typeof Info> & {
plugin_origins?: PluginOrigin[]
plugin_origins?: ConfigPlugin.Origin[]
}
type State = {
@@ -1074,7 +843,6 @@ export namespace Config {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1164,13 +932,14 @@ export namespace Config {
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service | Npm.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const npmSvc = yield* Npm.Service
const env = yield* Env.Service
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
@@ -1217,7 +986,7 @@ export namespace Config {
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
}
}
return data
@@ -1277,73 +1046,31 @@ export namespace Config {
return yield* cachedGlobal
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const REQUIRED_GITIGNORE_PATTERNS = [
"node_modules",
"package.json",
"package-lock.json",
"bun.lock",
".gitignore",
]
const ensureGitignore = Effect.fnUntraced(function* (dir: string) {
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 json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
const existing = yield* fs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
const missing = REQUIRED_GITIGNORE_PATTERNS.filter(
(p) => !existing.split("\n").some((line) => line.trim() === p),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
if (!missing.length) return
const content = existing ? existing + "\n" + missing.join("\n") : missing.join("\n")
yield* fs.writeFileString(gitignore, content)
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string) {
const target = Installation.isLocal() ? "" : "@" + Installation.VERSION
yield* npmSvc.install(dir, {
add: ["@opencode-ai/plugin" + target],
})
}, Effect.scoped)
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1359,10 +1086,14 @@ export namespace Config {
return "global"
})
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
const track = Effect.fnUntraced(function* (
source: string,
list: ConfigPlugin.Spec[] | undefined,
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
@@ -1370,7 +1101,7 @@ export namespace Config {
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: PluginScope) => {
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind)
}
@@ -1437,6 +1168,7 @@ export namespace Config {
}
}
yield* ensureGitignore(dir).pipe(Effect.ignore, Effect.forkScoped)
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
@@ -1454,7 +1186,7 @@ export namespace Config {
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* track(dir, list)
}
@@ -1642,7 +1374,6 @@ export namespace Config {
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
@@ -1657,5 +1388,6 @@ export namespace Config {
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
)
}

View File

@@ -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",
})
}

View File

@@ -8,11 +8,11 @@ import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
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

View File

@@ -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()
}
}

View File

@@ -47,8 +47,10 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { Npm } from "@opencode-ai/shared/npm"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,

View File

@@ -3,7 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
export namespace Observability {
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
@@ -22,9 +22,9 @@ export namespace Observability {
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"deployment.environment.name": InstallationChannel === "local" ? "local" : InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}

View File

@@ -420,7 +420,7 @@ export namespace File {
})
const init = Effect.fn("File.init")(function* () {
yield* ensure()
yield* ensure().pipe(Effect.forkChild)
})
const status = Effect.fn("File.status")(function* () {

View File

@@ -8,7 +8,7 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
import { Log } from "../util/log"
import { CHANNEL as channel, VERSION as version } from "./meta"
import { InstallationChannel, InstallationVersion } from "./version"
import semver from "semver"
@@ -55,12 +55,12 @@ export namespace Installation {
})
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 VERSION = InstallationVersion
export const CHANNEL = InstallationChannel
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
export function isPreview() {
return CHANNEL !== "latest"
return InstallationChannel !== "latest"
}
export function isLocal() {

View File

@@ -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"

View File

@@ -0,0 +1,7 @@
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"

View File

@@ -8,6 +8,7 @@ import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Arborist } from "@npmcli/arborist"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export namespace Npm {
const log = Log.create({ service: "npm" })
@@ -63,7 +64,10 @@ export namespace Npm {
export async function add(pkg: string) {
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
await using _ = await Flock.acquire(
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`,
)
log.info("installing package", {
pkg,
})
@@ -104,7 +108,9 @@ export namespace Npm {
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
await using _ = await Flock.acquire(
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`,
)
log.info("checking dependencies", { dir })
const reify = async () => {
@@ -115,7 +121,12 @@ export namespace Npm {
savePrefix: "",
ignoreScripts: true,
})
await arb.reify().catch(() => {})
await arb
.reify({
add: [],
save: true,
})
.catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {

View File

@@ -1,5 +1,3 @@
import { Config } from "@/config/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
}

View File

@@ -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) => {

View File

@@ -165,14 +165,20 @@ export namespace Vcs {
return { current: undefined, root: undefined }
}
const value: State = { current: undefined, root: undefined }
const get = Effect.fnUntraced(function* () {
return yield* git.branch(ctx.directory)
})
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* Effect.gen(function* () {
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
value.current = current
value.root = root
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
}).pipe(Effect.forkScoped)
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),

View File

@@ -9,7 +9,7 @@ import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { Config } from "../../config/config"
import { ConsoleState } from "../../config/console-state"
import { ConsoleState } from "@/cli/cmd/tui/config/console-state"
import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime"
import { zodToJsonSchema } from "zod-to-json-schema"

View File

@@ -142,46 +142,79 @@ export namespace Skill {
directory: string,
worktree: string,
) {
const cfg = yield* config.get()
const tasks: Effect.Effect<void>[] = []
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(yield* fsys.isDir(root))) continue
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
const externalScans = Effect.forEach(
EXTERNAL_DIRS,
(dir) =>
Effect.gen(function* () {
const root = path.join(Global.Path.home, dir)
if (yield* fsys.isDir(root)) {
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
}),
{ concurrency: "unbounded", discard: true },
)
tasks.push(externalScans)
const upDirs = yield* fsys
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const root of upDirs) {
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
const upScans = Effect.forEach(
upDirs,
(root) => scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }),
{ concurrency: "unbounded", discard: true },
)
tasks.push(upScans)
}
const configDirs = yield* config.directories()
for (const dir of configDirs) {
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
}
const configScans = Effect.forEach(configDirs, (dir) => scan(state, bus, dir, OPENCODE_SKILL_PATTERN), {
concurrency: "unbounded",
discard: true,
})
tasks.push(configScans)
const cfg = yield* config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!(yield* fsys.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
const pathChecks = Effect.forEach(
cfg.skills?.paths ?? [],
(item) =>
Effect.gen(function* () {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (yield* fsys.isDir(dir)) {
yield* scan(state, bus, dir, SKILL_PATTERN)
} else {
log.warn("skill path not found", { path: dir })
}
}),
{ concurrency: "unbounded", discard: true },
)
tasks.push(pathChecks)
yield* scan(state, bus, dir, SKILL_PATTERN)
}
const urlScans = Effect.forEach(
cfg.skills?.urls ?? [],
(url) =>
Effect.gen(function* () {
const pulledDirs = yield* discovery.pull(url)
yield* Effect.forEach(
pulledDirs,
(dir) =>
Effect.gen(function* () {
state.dirs.add(dir)
yield* scan(state, bus, dir, SKILL_PATTERN)
}),
{ concurrency: "unbounded", discard: true },
)
}),
{ concurrency: "unbounded", discard: true },
)
tasks.push(urlScans)
for (const url of cfg.skills?.urls ?? []) {
const pulledDirs = yield* discovery.pull(url)
for (const dir of pulledDirs) {
state.dirs.add(dir)
yield* scan(state, bus, dir, SKILL_PATTERN)
}
}
yield* Effect.all(tasks, { concurrency: "unbounded", discard: true })
log.info("init", { count: Object.keys(state.skills).length })
})

View File

@@ -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/instance-state"
import { iife } from "@/util/iife"
import { init } from "#db"
@@ -29,9 +29,10 @@ const log = Log.create({ service: "db" })
export namespace Database {
export function getChannelPath() {
if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
const channel = InstallationChannel
if (["latest", "beta", "prod"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
}

View File

@@ -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/tui"
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
}

View File

@@ -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/tui"
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
}

View File

@@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -40,10 +40,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")
@@ -100,13 +100,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
@@ -156,11 +156,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")
@@ -203,10 +203,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)

View File

@@ -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/tui"
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
}

View File

@@ -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/tui"
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

View File

@@ -5,7 +5,7 @@ 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/tui"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Filesystem } from "../../../src/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -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")

View File

@@ -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/tui"
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
}

View File

@@ -8,7 +8,6 @@ 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/tui"
import { Instance } from "../../../src/project/instance"
const stop = new Error("stop")
@@ -42,7 +41,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()

View File

@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config"
import { ConfigPlugin } from "../../src/config/plugin"
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
@@ -23,7 +24,7 @@ import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util/filesystem"
import * as Network from "../../src/util/network"
import { Npm } from "../../src/npm"
import { Npm } from "@opencode-ai/shared/npm"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -40,6 +41,7 @@ const layer = Config.layer.pipe(
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
Layer.provide(Npm.defaultLayer),
)
const it = testEffect(layer)
@@ -54,9 +56,6 @@ const listDirs = () =>
const ready = () =>
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input))
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -335,6 +334,7 @@ test("resolves env templates in account config with account token", async () =>
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(fakeAccount),
Layer.provideMerge(infra),
@@ -807,6 +807,13 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
init: async (dir) => {
const cfg = path.join(dir, "configdir")
await fs.mkdir(cfg, { recursive: true })
// Pre-create the plugin structure that Npm.install would create
const mod = path.join(cfg, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
return cfg
},
})
@@ -814,14 +821,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
const online = spyOn(Network, "online").mockReturnValue(false)
const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
})
try {
await Instance.provide({
@@ -837,7 +836,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
online.mockRestore()
install.mockRestore()
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev
}
@@ -845,69 +843,69 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
it.live("dedupes concurrent config dependency installs for the same dir", () =>
Effect.gen(function* () {
// Test that Npm.install properly serializes concurrent calls via Flock
const tmp = yield* tmpdirScoped()
const dir = path.join(tmp, "a")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
let calls = 0
const online = spyOn(Network, "online").mockReturnValue(false)
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const target = path.normalize(dir)
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
if (path.normalize(d) !== target) return
calls += 1
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
)
const first = yield* installDeps(dir).pipe(Effect.forkScoped)
// Create a mock Npm layer that tracks calls
const ready = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const target = path.normalize(dir)
const mockNpm = Layer.mock(Npm.Service)({
add: () => Effect.fail(new Npm.InstallFailedError({ pkg: "test" })),
install: (d: string, input?: { add: string[] }) =>
Effect.gen(function* () {
if (path.normalize(d) !== target) return
calls += 1
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
yield* Deferred.await(hold)
}
// Create node_modules to indicate install happened
yield* Effect.promise(() => fs.mkdir(path.join(d, "node_modules"), { recursive: true }))
}),
outdated: () => Effect.succeed(false),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Layer.mergeAll(mockNpm, AppFileSystem.defaultLayer, NodeFileSystem.layer)
// Run two concurrent installs to the same dir
const install = (d: string) =>
Npm.Service.use((npm) => npm.install(d, { add: ["@opencode-ai/plugin"] })).pipe(Effect.provide(testLayer))
const first = yield* install(dir).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
let done = false
const second = yield* installDeps(dir, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).pipe(
Effect.tap(() =>
Effect.sync(() => {
done = true
}),
),
Effect.forkScoped,
)
yield* Deferred.await(blocked)
expect(done).toBe(false)
const second = yield* install(dir).pipe(Effect.forkScoped)
// Release the hold
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
// With proper deduplication via Flock, we expect only 1 actual call
// Note: This test may show 2 calls if running concurrently without dedup
// The Flock mechanism in Npm.install should serialize them
expect(calls).toBeGreaterThanOrEqual(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "node_modules")))).toBe(true)
}),
)
it.live("serializes config dependency installs across dirs", () =>
Effect.gen(function* () {
if (process.platform !== "win32") return
// Test that Npm.install properly serializes installs across different dirs via Flock
const tmp = yield* tmpdirScoped()
const a = path.join(tmp, "a")
const b = path.join(tmp, "b")
@@ -917,58 +915,57 @@ it.live("serializes config dependency installs across dirs", () =>
let calls = 0
let open = 0
let peak = 0
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
open -= 1
}
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
)
const first = yield* installDeps(a).pipe(Effect.forkScoped)
const ready = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const mockNpm = Layer.mock(Npm.Service)({
add: () => Effect.fail(new Npm.InstallFailedError({ pkg: "test" })),
install: (dir: string) =>
Effect.gen(function* () {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
yield* Deferred.await(hold)
}
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "node_modules"), { recursive: true }))
open -= 1
}
}),
outdated: () => Effect.succeed(false),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Layer.mergeAll(mockNpm, AppFileSystem.defaultLayer, NodeFileSystem.layer)
const install = (dir: string) =>
Npm.Service.use((npm) => npm.install(dir, { add: ["@opencode-ai/plugin"] })).pipe(Effect.provide(testLayer))
const first = yield* install(a).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const second = yield* installDeps(b, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).pipe(Effect.forkScoped)
yield* Deferred.await(blocked)
expect(peak).toBe(1)
const second = yield* install(b).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
// Both dirs should have been processed
expect(calls).toBe(2)
expect(peak).toBe(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(a, "node_modules")))).toBe(true)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(b, "node_modules")))).toBe(true)
}),
)
@@ -1258,7 +1255,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("shared-plugin@2.0.0")
expect(names).not.toContain("shared-plugin@1.0.0")
@@ -1266,7 +1263,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
expect(names).toContain("local-only@1.0.0")
expect(origins.map((item) => item.spec)).toEqual(plugins)
const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
expect(hit?.scope).toBe("local")
},
})
@@ -1828,6 +1825,7 @@ test("project config overrides remote well-known config", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1884,6 +1882,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
const layer = Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(fakeAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
@@ -1909,8 +1908,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("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
})
test("resolves windows-style relative plugin directory specs", async () => {
@@ -1925,8 +1924,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 +1936,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 +1955,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 () => {
@@ -1970,14 +1969,14 @@ 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)
})
})
describe("deduplicatePluginOrigins", () => {
const dedupe = (plugins: Config.PluginSpec[]) =>
Config.deduplicatePluginOrigins(
const dedupe = (plugins: ConfigPlugin.Spec[]) =>
ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({
spec,
source: "",
@@ -2047,8 +2046,8 @@ describe("deduplicatePluginOrigins", () => {
const config = await load()
const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
},
})
})

View File

@@ -4,10 +4,13 @@ import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config"
import { TuiConfig } from "../../src/config/tui"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Effect, Layer } from "effect"
import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
import { ConfigPlugin } from "@/config/plugin"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip
@@ -18,6 +21,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
@@ -83,9 +93,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("shared-plugin@2.0.0")
@@ -93,8 +103,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 +123,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 +146,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 +181,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 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()
},
})
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 +208,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 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()
},
})
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 +232,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 +250,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 +270,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 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")
},
})
const server = JSON.parse(await Filesystem.readText(source))
expect(server.theme).toBe("readonly-theme")
} finally {
await fs.chmod(source, 0o644)
}
@@ -323,17 +298,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 +315,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 +334,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 +354,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 +369,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 +383,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 +401,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 +413,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 +426,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 +441,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 +461,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,14 +483,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")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("resolved-theme")
})
test("loads managed tui config and gives it highest precedence", async () => {
@@ -600,21 +502,16 @@ test("loads managed tui config and gives it highest precedence", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "shared-plugin@2.0.0",
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "shared-plugin@2.0.0",
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
})
])
})
test("loads .opencode/tui.json", async () => {
@@ -624,14 +521,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")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.diff_style).toBe("stacked")
})
test("gracefully falls back when tui.json has invalid JSON", async () => {
@@ -642,15 +533,9 @@ test("gracefully falls back when tui.json has invalid JSON", async () => {
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.theme).toBe("managed-fallback")
expect(config.keybinds).toBeDefined()
})
test("supports tuple plugin specs with options in tui.json", async () => {
@@ -665,20 +550,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([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
])
})
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
@@ -702,28 +582,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([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@2.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
{
spec: ["second-plugin@3.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@2.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
{
spec: ["second-plugin@3.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
})
test("tracks global and local plugin metadata in merged tui config", async () => {
@@ -744,25 +619,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(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "global-plugin@1.0.0",
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
{
spec: "local-plugin@2.0.0",
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "global-plugin@1.0.0",
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
})
{
spec: "local-plugin@2.0.0",
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
})
test("merges plugin_enabled flags across config layers", async () => {
@@ -789,15 +659,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,
})
})

View File

@@ -1,27 +1,31 @@
import { spyOn } from "bun:test"
import path from "path"
import { TuiConfig } from "../../src/config/tui"
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
},
}
}

View File

@@ -16,6 +16,7 @@ import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem"
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")

View File

@@ -10,7 +10,8 @@
"customConditions": ["browser"],
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
"@tui/*": ["./src/cli/cmd/tui/*"],
"@test/*": ["./test/*"]
},
"plugins": [
{

View File

@@ -17,7 +17,8 @@
"imports": {},
"devDependencies": {
"@types/semver": "catalog:",
"@types/bun": "catalog:"
"@types/bun": "catalog:",
"@types/npmcli__arborist": "6.3.3"
},
"dependencies": {
"@effect/platform-node": "catalog:",

View File

@@ -20,7 +20,7 @@ export namespace Npm {
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError>
readonly install: (dir: string) => Effect.Effect<void>
readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect<void>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
@@ -132,7 +132,13 @@ 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.effect(`npm-install:${dir}`)
const reify = Effect.fnUntraced(function* () {
@@ -144,7 +150,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(() => {}))
})
@@ -166,6 +179,7 @@ export namespace Npm {
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {}

View File

@@ -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)
})
}