mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b49bad9a86 | |||
| 2580f880a8 |
@@ -1,179 +0,0 @@
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
|
||||
readonly changes: () => Stream.Stream<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(fs, location, entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
return operations
|
||||
}),
|
||||
changes: () =>
|
||||
Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
Stream.fromPubSub(configuredChanges),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
|
||||
})
|
||||
|
||||
export const empty = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
operations: () => Effect.succeed([]),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
location: Location.Interface,
|
||||
entries: readonly Entry[],
|
||||
) {
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../agent"
|
||||
@@ -139,41 +137,6 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
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 { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Plugin } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
@@ -32,10 +63,65 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -82,9 +168,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
@@ -108,6 +192,31 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
@@ -115,29 +224,72 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// 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 operations = yield* sources.operations()
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -163,13 +315,48 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: nodeLayer,
|
||||
deps: [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
export { layer }
|
||||
|
||||
@@ -5,7 +5,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -25,11 +24,6 @@ import { testEffect } from "../lib/effect"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
@@ -163,29 +157,6 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
request?: ColorInput
|
||||
response?: ColorInput
|
||||
note?: ColorInput
|
||||
noteBackground?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +135,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
request: color(colors.request ?? colors.primary),
|
||||
response: color(colors.response ?? colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
note: color(colors.note ?? colors.warning),
|
||||
noteBg: color(colors.noteBackground ?? colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
|
||||
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const success = RGBA.fromInts(80, 180, 120)
|
||||
const warning = RGBA.fromInts(220, 160, 80)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
background,
|
||||
})
|
||||
|
||||
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
expect(palette.request).toBe(success)
|
||||
expect(palette.response).toBe(warning)
|
||||
expect(palette.note).toBe(primary)
|
||||
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly success: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
request: input.success,
|
||||
response: input.warning,
|
||||
note: input.text,
|
||||
noteBackground: blendColor(input.background, input.subdued, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export default Plugin.define({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
success: context.theme.text.feedback.success.default,
|
||||
warning: context.theme.text.feedback.warning.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -51,16 +51,15 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
Browser Server
|
||||
───┬─── ───┬──
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -140,13 +139,13 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
const browserCenter = lines[0]!.indexOf("w")
|
||||
const serverCenter = lines[0]!.indexOf("v")
|
||||
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
expect(lines[1]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[2]?.[browserCenter]).toBe("│")
|
||||
expect(lines[1]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[2]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
@@ -277,8 +276,8 @@ sequenceDiagram
|
||||
A->>B: hello`)
|
||||
|
||||
expect(output).not.toContain("<br")
|
||||
expect(output).toContain("│ First line │")
|
||||
expect(output).toContain("│ Second line │")
|
||||
expect(output).toContain("First line")
|
||||
expect(output).toContain("Second line")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
@@ -314,28 +313,27 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
" A B
|
||||
─┬─ ─┬─
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -387,7 +385,7 @@ sequenceDiagram
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
@@ -557,7 +555,7 @@ sequenceDiagram
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
@@ -600,7 +598,7 @@ sequenceDiagram
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("─")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
@@ -655,18 +653,17 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
" ╭─ Backend ───────────────────────────────╮
|
||||
Browser │ API Cache DB │
|
||||
───┬─── │ ─┬─ ──┬── ─┬─ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -706,18 +703,17 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
"Service
|
||||
───┬───
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("frames notes in their reserved rows", () => {
|
||||
test("renders note badges in their reserved rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
@@ -729,11 +725,9 @@ sequenceDiagram
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]).toContain("╭")
|
||||
expect(lines[noteRow - 1]).toContain("╮")
|
||||
expect(lines[noteRow]).toContain("│ phase │")
|
||||
expect(lines[noteRow + 1]).toContain("╰")
|
||||
expect(lines[noteRow + 1]).toContain("╯")
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow]).toContain(" phase ")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -191,29 +191,9 @@ function renderSelfMessage(
|
||||
}
|
||||
|
||||
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
|
||||
const width = Math.max(...placement.textLines.map(diagramTextWidth))
|
||||
const left = placement.textX
|
||||
const right = left + width - 1
|
||||
const top = placement.textY - 1
|
||||
const bottom = placement.textY + placement.textLines.length
|
||||
|
||||
for (let x = left + 1; x < right; x++) {
|
||||
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
|
||||
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
|
||||
}
|
||||
for (let y = top + 1; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
|
||||
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
|
||||
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
|
||||
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
|
||||
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
|
||||
for (let y = placement.textY; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
placement.textLines.forEach((line, index) =>
|
||||
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
|
||||
)
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
@@ -236,22 +216,12 @@ export function drawSequenceDiagramGrid(
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
} else {
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
|
||||
)
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
for (let y = participantHeaderY; y < participantRuleY; y++) {
|
||||
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
}
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ ${Array.from(
|
||||
expect(explicit.activations).toEqual(shorthand.activations)
|
||||
})
|
||||
|
||||
test("centers message label blocks over their arrow span", () => {
|
||||
test("left-aligns message label blocks inside their arrow span", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
@@ -214,8 +214,6 @@ ${Array.from(
|
||||
A->>B: short<br/>a much longer line`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
|
||||
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
|
||||
expect(message.labelX).toBe(message.leftX + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
const width = labelLinesWidth(mermaidLabelLines(label))
|
||||
return compact ? width : Math.max(5, width + 4)
|
||||
return compact ? width : Math.max(3, width)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
@@ -247,7 +247,7 @@ function getStepContentBounds(
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const labelWidth = messageWidth(step.message)
|
||||
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
|
||||
const labelLeftX = leftX + 2
|
||||
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
|
||||
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
|
||||
)
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
|
||||
const participantHeaderY = participantHeaderTopY
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
|
||||
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
|
||||
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
|
||||
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { defineScript, Effect, Llm } from "opencode-drive"
|
||||
|
||||
const theme = Bun.env.DRIVE_THEME ?? "opencode"
|
||||
const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
|
||||
const animate = Bun.env.DRIVE_ANIMATE === "1"
|
||||
const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
|
||||
|
||||
const response = `\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant S as Server
|
||||
participant T as Ticket store
|
||||
participant P as PTY
|
||||
B->>S: GET /
|
||||
S-->>B: 401 WWW-Auth
|
||||
Note over B,S: native browser Basic prompt
|
||||
B->>S: GET / · Basic
|
||||
S-->>B: 200 web UI
|
||||
Note over B,S: user opens terminal
|
||||
B->>S: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
|
||||
S->>T: issue { ptyID, … }
|
||||
S-->>B: { ticket }
|
||||
B->>S: WS …?ticket=…<br/>Upgrade: websocket
|
||||
S->>T: consume(token,scope)
|
||||
T-->>S: ok, delete
|
||||
S->>P: attach
|
||||
P-->>B: WS frames
|
||||
\`\`\``
|
||||
|
||||
export default defineScript({
|
||||
config: {
|
||||
autoupdate: false,
|
||||
},
|
||||
tuiConfig: {
|
||||
theme: {
|
||||
name: theme,
|
||||
mode: "dark",
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
viewport: { cols: 180, rows: 64 },
|
||||
},
|
||||
run: ({ ui, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
|
||||
yield* llm.send(
|
||||
Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
|
||||
)
|
||||
yield* ui.waitFor("WS frames", { timeout: 10_000 })
|
||||
if (cycleThemes) {
|
||||
yield* Effect.sleep(800)
|
||||
yield* Effect.forEach(
|
||||
["everforest", "synthwave84", "matrix", "opencode"],
|
||||
(next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.press("x", { ctrl: true })
|
||||
yield* ui.press("t")
|
||||
yield* ui.waitFor("Themes")
|
||||
yield* ui.type(next)
|
||||
yield* Effect.sleep(700)
|
||||
yield* ui.enter()
|
||||
yield* Effect.sleep(1_200)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
|
||||
yield* Effect.promise(async () => {
|
||||
await mkdir("artifacts", { recursive: true })
|
||||
await Bun.write(output, Bun.file(screenshot))
|
||||
})
|
||||
yield* Effect.log(`Saved ${output}`)
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user