mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d83c61b6f | |||
| e6d7dae31c | |||
| 103105d52a | |||
| bac474aaa0 |
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
@@ -14,11 +14,18 @@ export default Runtime.handler(
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ export type AgentColor = string
|
||||
|
||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginInfo = { id: string }
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; package: string }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -196,6 +200,10 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type TokenUsageInfo = {
|
||||
input: number
|
||||
output: number
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.formatter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(formatter.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* formatter.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
if (configured === false) {
|
||||
draft.clear()
|
||||
return
|
||||
}
|
||||
if (configured === undefined) {
|
||||
for (const item of draft.list()) if (item.builtIn) draft.remove(item.name)
|
||||
return
|
||||
}
|
||||
if (configured === true) return
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
if (entry.disabled) {
|
||||
draft.remove(name)
|
||||
continue
|
||||
}
|
||||
const current = draft.get(name)
|
||||
draft.set(name, {
|
||||
name,
|
||||
extensions: entry.extensions ?? current?.extensions ?? [],
|
||||
environment: { ...current?.environment, ...entry.environment },
|
||||
enabled:
|
||||
current && !entry.command ? current.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.image",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(image.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* image.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.media?.image) continue
|
||||
draft.update((policy) => {
|
||||
const configured = entry.info.media?.image
|
||||
if (!configured) return
|
||||
if (configured.auto_resize !== undefined) policy.autoResize = configured.auto_resize
|
||||
if (configured.max_width !== undefined) policy.maxWidth = configured.max_width
|
||||
if (configured.max_height !== undefined) policy.maxHeight = configured.max_height
|
||||
if (configured.max_base64_bytes !== undefined) policy.maxBase64Bytes = configured.max_base64_bytes
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
export * as ConfigToolOutputPlugin from "./tool-output.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.tool-output",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const output = yield* ToolOutput.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(output.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* output.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "tool_output")
|
||||
if (!configured) return
|
||||
draft.update((policy) => {
|
||||
if (configured.max_lines !== undefined) policy.maxLines = configured.max_lines
|
||||
if (configured.max_bytes !== undefined) policy.maxBytes = configured.max_bytes
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -8,23 +8,11 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import { make, type Info } from "./formatter/builtins.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
type Data = {
|
||||
readonly formatters: Map<string, Info>
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly list: () => readonly Info[]
|
||||
readonly get: (name: string) => Info | undefined
|
||||
readonly set: (name: string, formatter: Info) => void
|
||||
readonly remove: (name: string) => void
|
||||
readonly clear: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -33,48 +21,66 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new WeakMap<Info, string[] | false>()
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "formatter",
|
||||
initial: () => ({
|
||||
formatters: new Map(builtIns.map((formatter) => [formatter.name, { ...formatter, builtIn: true }])),
|
||||
}),
|
||||
draft: (data) => ({
|
||||
list: () => Array.from(data.formatters.values()),
|
||||
get: (name) => data.formatters.get(name),
|
||||
set: (name, formatter) => data.formatters.set(name, { ...formatter, name }),
|
||||
remove: (name) => {
|
||||
data.formatters.delete(name)
|
||||
},
|
||||
clear: () => data.formatters.clear(),
|
||||
}),
|
||||
})
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter)
|
||||
const cached = commands.get(formatter.name)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter, result)
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
const matching = Array.from(state.get().formatters.values()).filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
@@ -112,12 +118,12 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ file, transform: state.transform, reload: state.reload })
|
||||
return Service.of({ file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { which } from "../util/which.js"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly builtIn?: boolean
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
|
||||
+15
-33
@@ -2,9 +2,8 @@ export * as Image from "./image.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
@@ -33,18 +32,7 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -59,18 +47,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -81,17 +58,22 @@ const layer = Layer.effect(
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
) {
|
||||
const policy = state.get()
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: policy.autoResize,
|
||||
maxWidth: policy.maxWidth,
|
||||
maxHeight: policy.maxHeight,
|
||||
maxBase64Bytes: policy.maxBase64Bytes,
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
return Service.of({ normalize, transform: state.transform, reload: state.reload })
|
||||
return Service.of({ normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
||||
+47
-15
@@ -1,15 +1,14 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app.js"
|
||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
@@ -24,11 +23,17 @@ import { Tool } from "./tool.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
|
||||
readonly activate: (
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
|
||||
) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
}
|
||||
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
@@ -39,6 +44,7 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
|
||||
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
@@ -57,15 +63,18 @@ const layer = Layer.effect(
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(loaded)) return child
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return undefined
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Versioned[],
|
||||
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
const ids = new Set<Plugin.ID>()
|
||||
for (const definition of definitions) {
|
||||
@@ -86,26 +95,40 @@ const layer = Layer.effect(
|
||||
const candidate = next[index]
|
||||
return definition.id === candidate?.id && definition.version === candidate.version
|
||||
})
|
||||
)
|
||||
) {
|
||||
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded })
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
status: "failed",
|
||||
error: loaded.error,
|
||||
tui: definition.tui ?? false,
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored })
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -120,6 +143,7 @@ const layer = Layer.effect(
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -137,7 +161,7 @@ const layer = Layer.effect(
|
||||
const service = Service.of({
|
||||
activate,
|
||||
list: Effect.fn("Plugin.list")(function* () {
|
||||
return Array.from(active.keys()).map((id) => ({ id }))
|
||||
return inventory
|
||||
}),
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
@@ -145,6 +169,15 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
status: "active",
|
||||
tui: plugin.tui ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
@@ -155,7 +188,6 @@ export const node = makeLocationNode({
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Formatter.node,
|
||||
Integration.node,
|
||||
MCP.node,
|
||||
Location.node,
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AISDK } from "../aisdk.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { Location } from "../location.js"
|
||||
@@ -37,7 +36,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
@@ -187,22 +185,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
formatter: {
|
||||
reload: formatter.reload,
|
||||
transform: (callback) =>
|
||||
formatter.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.set(definition.name, {
|
||||
name: definition.name,
|
||||
extensions: [...definition.extensions],
|
||||
environment: definition.environment === undefined ? undefined : { ...definition.environment },
|
||||
enabled: Effect.succeed([...definition.command]),
|
||||
}),
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
|
||||
@@ -12,8 +12,6 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
@@ -22,7 +20,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
@@ -60,7 +57,6 @@ import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
@@ -115,7 +111,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
@@ -154,7 +149,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(ToolOutput.Service, toolOutput),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
@@ -200,7 +194,6 @@ export const requirements = LayerNode.group([
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
@@ -239,9 +232,6 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigToolOutputPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
effect: Schema.declare<PluginDefinition["effect"]>(
|
||||
(input): input is PluginDefinition["effect"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
tui: Schema.optional(Schema.Boolean),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
@@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
|
||||
const plugins = () => [...definitions, ...packages.values()]
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "remove") {
|
||||
if (operation.target === "*") failures.clear()
|
||||
plugins()
|
||||
.filter((plugin) => matches(operation.target, plugin.id))
|
||||
.forEach((plugin) => enabled.delete(plugin.id))
|
||||
@@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
|
||||
const plugin = yield* load(operation).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!plugin) continue
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
status: "failed",
|
||||
error: plugin.error,
|
||||
tui: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
return [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
]
|
||||
return {
|
||||
plugins: [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
}
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
@@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
@@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
version: JSON.stringify(operation),
|
||||
source: pluginSource(operation.target),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
@@ -129,13 +149,20 @@ export const layer = Layer.effect(
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
version: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
@@ -172,4 +199,9 @@ const nodeDeps = [
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
function pluginSource(target: string): Plugin.Source {
|
||||
if (path.isAbsolute(target)) return { type: "local", path: target }
|
||||
return { type: "package", package: target }
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -6,9 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
@@ -17,16 +16,7 @@ export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
export interface Policy {
|
||||
readonly maxLines: number
|
||||
readonly maxBytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -56,23 +46,19 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "tool-output",
|
||||
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const { maxLines, maxBytes } = state.get()
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
@@ -127,12 +113,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
truncate,
|
||||
cleanup: () => cleanup(fs, directory),
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
})
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -156,5 +137,5 @@ const cleanupNode = makeGlobalNode({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, cleanupNode],
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("ConfigImagePlugin.Plugin", () => {
|
||||
it.live("materializes image policy from config", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = {
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}
|
||||
const image = Image.Service.of({
|
||||
normalize: () => Effect.die("unused image.normalize"),
|
||||
reload: () => Effect.void,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
callback({ update: (update) => update(policy) })
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
})
|
||||
yield* ConfigImagePlugin.Plugin.effect(host()).pipe(
|
||||
Effect.provideService(Image.Service, image),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({
|
||||
auto_resize: false,
|
||||
max_width: 1_000,
|
||||
max_height: 800,
|
||||
max_base64_bytes: 123_456,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(policy).toEqual({
|
||||
autoResize: false,
|
||||
maxWidth: 1_000,
|
||||
maxHeight: 800,
|
||||
maxBase64Bytes: 123_456,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ready()
|
||||
expect(
|
||||
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
|
||||
(yield* plugins.list())
|
||||
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
|
||||
.filter((id) => id.startsWith("opencode.provider.")),
|
||||
).toEqual([Plugin.ID.make("opencode.provider.openai")])
|
||||
}),
|
||||
),
|
||||
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
|
||||
id: Plugin.ID.make("config-promise-plugin"),
|
||||
source: {
|
||||
type: "local",
|
||||
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
},
|
||||
status: "active",
|
||||
tui: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
])
|
||||
expect(
|
||||
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
|
||||
).toEqual([
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
|
||||
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
|
||||
])
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
const inventory = yield* plugins.list()
|
||||
const ids = inventory.map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
|
||||
@@ -7,32 +7,33 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { ConfigFormatterPlugin } from "../src/config/plugin/formatter"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const layer = AppNodeBuilder.build(Formatter.node, [
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[Config.node, Config.testLayer(entries)],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ formatter: configured }) })]
|
||||
return Layer.effectDiscard(ConfigFormatterPlugin.Plugin.effect(host()).pipe(Effect.provide(Config.testLayer(entries)))).pipe(
|
||||
Layer.provideMerge(layer),
|
||||
)
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
@@ -161,34 +162,4 @@ describe("Formatter", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a replacement formatter command independently", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const formatter = yield* Formatter.Service
|
||||
const file = path.join(directory, "test.replaced")
|
||||
const register = (content: string) =>
|
||||
formatter.transform((draft) => {
|
||||
draft.set("replacement", {
|
||||
name: "replacement",
|
||||
extensions: [".replaced"],
|
||||
enabled: Effect.succeed([
|
||||
process.execPath,
|
||||
"-e",
|
||||
`require('fs').writeFileSync(process.argv.at(-1), '${content}')`,
|
||||
"$FILE",
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
yield* register("first")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("first")
|
||||
|
||||
yield* register("second")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("second")
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,4 @@ import { Effect, Layer } from "effect"
|
||||
/** Passthrough resizer for tests that build Tool.node without a Location. */
|
||||
export const imagePassthrough = Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) => Effect.succeed(content),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
})
|
||||
|
||||
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).length === 0) break
|
||||
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect(yield* registry.list()).toEqual([])
|
||||
expect(yield* registry.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("failing-plugin"),
|
||||
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("plugin failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -17,8 +16,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
@@ -92,41 +89,7 @@ describe("Plugin", () => {
|
||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||
expect(routed).toEqual([
|
||||
"add:/target",
|
||||
"remove:/target",
|
||||
"connect:/target",
|
||||
"disconnect:/target",
|
||||
"list:/target",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers formatters through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const file = path.join(location.directory, "plugin.formatter-test")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "before"))
|
||||
const registration = yield* host.formatter.transform((draft) => {
|
||||
draft.add({
|
||||
name: "plugin",
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); fs.writeFileSync(process.argv.at(-1), 'after')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".formatter-test"],
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("after")
|
||||
yield* registration.dispose
|
||||
expect(yield* formatter.file(file)).toBe(false)
|
||||
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -169,9 +132,22 @@ describe("Plugin", () => {
|
||||
expect(updates).toBe(2)
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
yield* plugins.activate(
|
||||
[versioned(managed(), "2")],
|
||||
[
|
||||
{
|
||||
source: { type: "package", package: "broken" },
|
||||
status: "failed",
|
||||
error: "failed to resolve",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(updates).toBe(3)
|
||||
|
||||
yield* plugins.activate([])
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
expect(updates).toBe(3)
|
||||
expect(updates).toBe(4)
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
@@ -191,7 +167,7 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(yield* plugins.list()).toEqual([{ id: active }])
|
||||
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -220,12 +196,24 @@ describe("Plugin", () => {
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: Plugin.ID.make("bad"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("materialization failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -260,7 +248,15 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
|
||||
}),
|
||||
)
|
||||
@@ -292,7 +288,15 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("managed"),
|
||||
source: { type: "builtin" },
|
||||
status: "failed",
|
||||
error: expect.stringContaining("replacement failed"),
|
||||
tui: false,
|
||||
},
|
||||
])
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
@@ -45,7 +44,6 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Form.node,
|
||||
Formatter.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
Plugin.node,
|
||||
Agent.node,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "config-promise-plugin",
|
||||
tui: true,
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("configured", (agent) => {
|
||||
|
||||
@@ -48,10 +48,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
formatter: overrides.formatter ?? {
|
||||
transform: () => Effect.die("unused formatter.transform"),
|
||||
reload: () => Effect.die("unused formatter.reload"),
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -13,7 +12,6 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
@@ -23,12 +21,10 @@ const withStore = <A, E, R>(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Config.testLayer([new Document({ type: "document", info })])
|
||||
const base = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
])
|
||||
const layer = Layer.effectDiscard(
|
||||
ConfigToolOutputPlugin.Plugin.effect(host()).pipe(Effect.provide(config)),
|
||||
).pipe(Layer.provideMerge(base))
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -26,7 +25,6 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
@@ -92,8 +90,7 @@ const permission = permissionLayer({
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node)
|
||||
const configureImage = ConfigImagePlugin.Plugin.effect(host())
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -135,15 +132,11 @@ const mutation = Layer.succeed(
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
}),
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode, Image.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
@@ -402,7 +395,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -444,7 +436,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -486,7 +477,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -25,7 +24,6 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
@@ -39,6 +37,7 @@ export interface Context {
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
readonly id: string
|
||||
readonly tui?: boolean
|
||||
readonly effect: (context: Context) => Effect.Effect<void, never, R>
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
|
||||
@@ -158,10 +159,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
formatter: {
|
||||
transform: transform(host.formatter),
|
||||
reload: () => run(host.formatter.reload()),
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -24,7 +23,6 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi
|
||||
@@ -40,6 +38,7 @@ export type Cleanup = () => Promise<void> | void
|
||||
|
||||
export interface Plugin {
|
||||
readonly id: string
|
||||
readonly tui?: boolean
|
||||
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.list",
|
||||
summary: "List plugins",
|
||||
description: "Retrieve currently loaded plugins.",
|
||||
description: "Retrieve enabled server plugins and their current status.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,35 @@ export * as Plugin from "./plugin.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
}).annotate({ identifier: "Plugin.Info" })
|
||||
export const Source = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("builtin") }),
|
||||
Schema.Struct({ type: Schema.Literal("package"), package: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("sdk") }),
|
||||
]).annotate({ identifier: "Plugin.Source" })
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Info = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
source: Source,
|
||||
status: Schema.Literal("active"),
|
||||
tui: Schema.Boolean,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID.pipe(optional),
|
||||
source: Source,
|
||||
status: Schema.Literal("failed"),
|
||||
error: Schema.String,
|
||||
tui: Schema.Boolean,
|
||||
}),
|
||||
]).annotate({ identifier: "Plugin.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -20,19 +18,21 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [scrollable, setScrollable] = createSignal(false)
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
const [height, setHeight] = createSignal(1)
|
||||
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let measure: (() => void) | undefined
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
createEffect(() => {
|
||||
dimensions()
|
||||
props.error
|
||||
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
|
||||
measure = () => {
|
||||
measure = undefined
|
||||
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
|
||||
if (!scroll) return
|
||||
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
|
||||
setHeight(next)
|
||||
setScrollable(scroll.scrollHeight > next)
|
||||
}
|
||||
renderer.once(CliRenderEvents.FRAME, measure)
|
||||
renderer.requestRender()
|
||||
@@ -61,15 +61,15 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
if (!scrollable()) return
|
||||
if (event.name === "up") return scroll?.scrollBy(-1)
|
||||
if (event.name === "down") return scroll?.scrollBy(1)
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-height())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(height())
|
||||
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
|
||||
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
|
||||
if (event.name === "home") return scroll?.scrollTo(0)
|
||||
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
{props.title}
|
||||
</text>
|
||||
@@ -77,7 +77,6 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background.default}
|
||||
paddingLeft={2}
|
||||
@@ -96,7 +95,7 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
<b>{scrollable() ? "↑/↓" : ""}</b>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { projectName } from "../util/project"
|
||||
import { useLocation } from "../context/location"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -36,6 +37,7 @@ export function DialogSessionList() {
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const activeLocation = useLocation()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
@@ -44,13 +46,21 @@ export function DialogSessionList() {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const allProjects = () => prefs.allProjects
|
||||
const pickerLocation = () =>
|
||||
(route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined) ??
|
||||
activeLocation.ref ??
|
||||
data.location.default()
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
async ({ query, allProjects }) => {
|
||||
() => ({
|
||||
query: search().trim(),
|
||||
allProjects: allProjects(),
|
||||
location: pickerLocation(),
|
||||
}),
|
||||
async ({ query, allProjects, location }) => {
|
||||
try {
|
||||
if (!data.location.info()) await data.location.sync()
|
||||
const current = data.location.info()
|
||||
if (!data.location.info(location)) await data.location.sync(location)
|
||||
const current = data.location.info(location)
|
||||
if (!current) throw new Error("Location unavailable")
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
@@ -78,7 +88,7 @@ export function DialogSessionList() {
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const localSessions = createMemo(() => {
|
||||
const query = filter().trim().toLowerCase()
|
||||
const current = data.location.info()
|
||||
const current = data.location.info(pickerLocation())
|
||||
const sessions = data.session
|
||||
.list()
|
||||
.filter(
|
||||
@@ -125,7 +135,7 @@ export function DialogSessionList() {
|
||||
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
||||
})
|
||||
const currentProjectName = createMemo(() => {
|
||||
const current = data.location.info()
|
||||
const current = data.location.info(pickerLocation())
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
return projectName(project) ?? ""
|
||||
|
||||
@@ -1,68 +1,114 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
|
||||
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
|
||||
type Entry =
|
||||
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
|
||||
| {
|
||||
readonly key: string
|
||||
readonly runtime: "tui"
|
||||
readonly id?: string
|
||||
readonly target: string
|
||||
readonly status: "active" | "inactive" | "failed"
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export function PluginsDialog(props: {
|
||||
context: Plugin.Context
|
||||
plugins: ReturnType<typeof usePlugin>
|
||||
server?: () => readonly PluginInfo[]
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const [locked, setLocked] = createSignal(false)
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
|
||||
const dialog = useDialog()
|
||||
const options = createMemo(() => {
|
||||
const builtins = props.plugins
|
||||
const [detail, setDetail] = createSignal<Entry>()
|
||||
const [initial, setInitial] = createSignal<string>()
|
||||
const [server] = createResource(
|
||||
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
|
||||
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
|
||||
)
|
||||
onMount(() => dialog.setSize("medium"))
|
||||
const entries = createMemo<Entry[]>(() => {
|
||||
const builtins: Entry[] = props.plugins
|
||||
.registered()
|
||||
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id,
|
||||
value: plugin.id,
|
||||
category: "Built-in",
|
||||
footer: plugin.active ? "active" : "inactive",
|
||||
footerColor: plugin.active
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: props.context.theme.text.subdued,
|
||||
}),
|
||||
)
|
||||
const external = props.plugins
|
||||
.map((plugin) => ({
|
||||
key: `tui:${plugin.id}`,
|
||||
runtime: "tui" as const,
|
||||
id: plugin.id,
|
||||
target: plugin.id,
|
||||
status: plugin.active ? ("active" as const) : ("inactive" as const),
|
||||
}))
|
||||
const external: Entry[] = props.plugins
|
||||
.list()
|
||||
.filter((plugin) => plugin.status !== "unsupported")
|
||||
.map(
|
||||
(plugin): DialogSelectOption<string> => ({
|
||||
title: plugin.id ?? plugin.target,
|
||||
value: plugin.id ?? plugin.target,
|
||||
category: "External",
|
||||
searchText: plugin.target,
|
||||
footer: plugin.status,
|
||||
footerColor:
|
||||
plugin.status === "active"
|
||||
? props.context.theme.text.feedback.success.default
|
||||
: plugin.status === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: props.context.theme.text.subdued,
|
||||
}),
|
||||
)
|
||||
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((plugin) => ({
|
||||
key: `tui:${plugin.id ?? plugin.target}`,
|
||||
runtime: "tui" as const,
|
||||
id: plugin.id,
|
||||
target: plugin.target,
|
||||
status: plugin.status,
|
||||
error: plugin.status === "failed" ? plugin.error : undefined,
|
||||
}))
|
||||
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
|
||||
key: `server:${plugin.id ?? source(plugin, props.context)}`,
|
||||
runtime: "server" as const,
|
||||
plugin,
|
||||
}))
|
||||
return [
|
||||
...[...builtins, ...external].sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
]
|
||||
})
|
||||
|
||||
const failure = (value: string | undefined) =>
|
||||
props.plugins.list().find((plugin) => {
|
||||
if (plugin.status !== "failed") return false
|
||||
return (plugin.id ?? plugin.target) === value
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (focused()) return
|
||||
const first = options()[0]
|
||||
if (first) setFocused(first.value)
|
||||
if (initial()) return
|
||||
const first = entries().find((entry) => entry.runtime === "tui")
|
||||
if (!first) return
|
||||
setInitial(first.key)
|
||||
setFocused(first.key)
|
||||
})
|
||||
|
||||
const toggle = (plugin: DialogSelectOption<string>) => {
|
||||
if (locked()) return
|
||||
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||
const options = createMemo(() =>
|
||||
entries().map(
|
||||
(entry): DialogSelectOption<string> => ({
|
||||
title: label(entry, props.context),
|
||||
value: entry.key,
|
||||
category: entry.runtime === "tui" ? "TUI" : "Server",
|
||||
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
|
||||
footer: status(entry) === "active" ? undefined : status(entry),
|
||||
footerColor:
|
||||
status(entry) === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: props.context.theme.text.subdued,
|
||||
gutter:
|
||||
status(entry) === "active"
|
||||
? () => <text fg={props.context.theme.text.feedback.success.default}>✓</text>
|
||||
: status(entry) === "failed"
|
||||
? () => <text fg={props.context.theme.text.feedback.error.default}>✗</text>
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
|
||||
const focusedTui = createMemo(() => {
|
||||
const entry = focusedEntry()
|
||||
if (entry?.runtime !== "tui" || !entry.id) return
|
||||
return entry
|
||||
})
|
||||
const toggleTitle = createMemo(() => {
|
||||
const entry = focusedTui()
|
||||
if (!entry) return "toggle"
|
||||
return props.plugins.registered().find((plugin) => plugin.id === entry.id)?.active ? "disable" : "enable"
|
||||
})
|
||||
const toggle = (entry: Entry | undefined) => {
|
||||
if (locked() || entry?.runtime !== "tui" || !entry.id) return
|
||||
const current = props.plugins.registered().find((plugin) => plugin.id === entry.id)
|
||||
if (!current) return
|
||||
setLocked(true)
|
||||
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
||||
@@ -70,21 +116,15 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
if (ok) return
|
||||
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
|
||||
const select = (plugin: DialogSelectOption<string>) => {
|
||||
const failed = failure(plugin.value)
|
||||
if (!failed || failed.status !== "failed") return toggle(plugin)
|
||||
setDetail({ title: failed.target, error: failed.error })
|
||||
}
|
||||
|
||||
return (
|
||||
<box>
|
||||
<Show
|
||||
@@ -93,33 +133,42 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
<DialogSelect
|
||||
title="Plugins"
|
||||
options={options()}
|
||||
current={initial()}
|
||||
locked={locked()}
|
||||
preserveSelection={true}
|
||||
onMove={(option) => setFocused(option.value)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
command: "plugins.toggle",
|
||||
disabled: (option) => {
|
||||
const failed = failure(option?.value)
|
||||
return Boolean(failed && !("id" in failed && failed.id))
|
||||
},
|
||||
onTrigger: toggle,
|
||||
},
|
||||
]}
|
||||
onSelect={select}
|
||||
onSelect={(option) => {
|
||||
const entry = entries().find((entry) => entry.key === option.value)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
}}
|
||||
actions={
|
||||
focusedTui()
|
||||
? [
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
footer={
|
||||
<Show when={failure(focused())}>
|
||||
<text fg={props.context.theme.text.subdued}>enter to view error</text>
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<text>
|
||||
<span style={{ fg: props.context.theme.text.default }}>
|
||||
<b>enter</b>
|
||||
</span>
|
||||
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
{(entry) => (
|
||||
<DialogErrorDetails
|
||||
title={`Plugin: ${item().title}`}
|
||||
error={item().error}
|
||||
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
|
||||
error={pluginError(entry()) ?? "Unknown plugin error"}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
@@ -131,6 +180,27 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
||||
)
|
||||
}
|
||||
|
||||
function label(entry: Entry, context: Plugin.Context) {
|
||||
if (entry.runtime === "tui") return entry.id ?? entry.target
|
||||
return entry.plugin.id ?? source(entry.plugin, context)
|
||||
}
|
||||
|
||||
function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return context.ui.format.path(plugin.source.path)
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
function status(entry: Entry) {
|
||||
if (entry.runtime === "server") return entry.plugin.status
|
||||
return entry.status
|
||||
}
|
||||
|
||||
function pluginError(entry: Entry | undefined) {
|
||||
if (entry?.runtime === "server") return entry.plugin.status === "failed" ? entry.plugin.error : undefined
|
||||
return entry?.error
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const plugins = usePlugin()
|
||||
props.context.keymap.layer(() => ({
|
||||
@@ -143,7 +213,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
slash: { name: "plugins" },
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
|
||||
props.context.ui.dialog.show(() => <PluginsDialog context={props.context} plugins={plugins} />)
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogSessionList } from "../../../src/component/dialog-session-list"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ArgsProvider } from "../../../src/context/args"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocalProvider } from "../../../src/context/local"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { PermissionProvider } from "../../../src/context/permission"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
||||
import { StorageProvider, useStorage } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("scopes sessions to the active session location", async () => {
|
||||
const active = "/tmp/opencode/project-b"
|
||||
const events = createEventStream()
|
||||
const requestedProjects: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
|
||||
const project = directory === active ? "proj_b" : "proj_a"
|
||||
return json({ directory, project: { id: project, directory, canonical: directory } })
|
||||
}
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
const project = url.searchParams.get("project") ?? ""
|
||||
requestedProjects.push(project)
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: project === "proj_b" ? "ses_b" : "ses_a",
|
||||
projectID: project,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: project === "proj_b" ? "Project B session" : "Project A session",
|
||||
location: { directory: project === "proj_b" ? active : process.cwd() },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
const temporary = await tmpdir()
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
|
||||
function Probe() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
storage = useStorage()
|
||||
onMount(() => {
|
||||
data.session.remember({
|
||||
id: "ses_active",
|
||||
projectID: "proj_b",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Active session",
|
||||
location: { directory: active },
|
||||
})
|
||||
route.navigate({ type: "session", sessionID: "ses_active" })
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ArgsProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<LocalProvider>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
const frame = await app.waitForFrame((value) => value.includes("Project B session"))
|
||||
expect(frame).not.toContain("Project A session")
|
||||
expect(requestedProjects.at(-1)).toBe("proj_b")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
await storage.flush()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user