mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f38c31493 |
@@ -93,7 +93,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
yield* reconcile(policy.current())
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
|
||||
@@ -8,12 +8,10 @@ import { Context, Effect } from "effect"
|
||||
*/
|
||||
export interface Interface {
|
||||
/**
|
||||
* Wait for configured plugin activation to settle, including missing-package installs.
|
||||
* Completion does not imply every plugin succeeded.
|
||||
* Interrupting this wait does not cancel activation. Use rarely: avoid blocking reads,
|
||||
* UI startup, or unrelated work on plugin boot.
|
||||
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
|
||||
* UI startup, or other unrelated work on plugin boot should be avoided.
|
||||
*/
|
||||
readonly awaitActivation: Effect.Effect<void>
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
@@ -189,7 +189,7 @@ export const layer = Layer.effect(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
|
||||
return Service.of({ awaitActivation: ready.await })
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ const layer = Layer.effect(
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
|
||||
@@ -125,7 +125,7 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
|
||||
@@ -37,7 +37,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
|
||||
messageID: SessionMessage.ID
|
||||
input: Input
|
||||
}) {
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID: request.messageID,
|
||||
|
||||
@@ -38,7 +38,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
@@ -66,7 +66,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
})
|
||||
|
||||
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
if (!session.revert) return
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
|
||||
@@ -57,7 +57,7 @@ const layer = Layer.effect(
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
|
||||
@@ -196,7 +196,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
// Resolve shell services here without pinning Session events to this Location after a move.
|
||||
const shell = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
return yield* Shell.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const started = yield* shell
|
||||
|
||||
@@ -490,7 +490,7 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("unblocks awaitActivation when plugin activation fails", () =>
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
@@ -530,9 +530,9 @@ describe("PluginSupervisor config", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
expect(Option.isNone(yield* supervisor.awaitActivation.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
|
||||
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
|
||||
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
|
||||
yield* supervisor.awaitActivation.pipe(Effect.timeout("2 seconds"))
|
||||
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
|
||||
}),
|
||||
),
|
||||
@@ -542,7 +542,7 @@ describe("PluginSupervisor config", () => {
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
})
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
|
||||
@@ -31,7 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -313,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ awaitActivation: Effect.void })
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -31,7 +31,7 @@ export const promptLocationNode = makeGlobalNode({
|
||||
}),
|
||||
Layer.succeed(FSUtil.Service, fs),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
awaitActivation: Effect.void,
|
||||
flush: Effect.void,
|
||||
}),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ function withFormatter<A, E, R>(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
return yield* body(yield* Formatter.Service, directory)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("InstancePlugins", () => {
|
||||
const agents = (ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const service = yield* Agent.Service
|
||||
return {
|
||||
bound: yield* service.get(Agent.ID.make("thread-a-agent")),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Instance vanilla", () => {
|
||||
const read = (ref: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const config = yield* Config.Service
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
@@ -145,7 +145,7 @@ describe("Instance vanilla", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const config = yield* Config.Service
|
||||
// Only the pathless host-injected document; nothing file-backed.
|
||||
const entries = yield* config.entries()
|
||||
|
||||
@@ -199,7 +199,7 @@ describe("LocationServiceMap", () => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const agents = yield* Agent.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
@@ -233,14 +233,14 @@ describe("LocationServiceMap", () => {
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
@@ -281,7 +281,7 @@ describe("LocationServiceMap", () => {
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
@@ -296,10 +296,10 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* Fiber.join(flushFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -352,22 +352,22 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Fiber.join(updated)
|
||||
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* Fiber.join(flushFiber)
|
||||
expect(activations.count).toBe(2)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps awaitActivation pending while startup updates continue", () =>
|
||||
itWithSdk.live("keeps flush pending while startup updates continue", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -376,7 +376,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
@@ -387,8 +387,8 @@ describe("LocationServiceMap", () => {
|
||||
() => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Fiber.join(activationFiber)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Fiber.join(flushFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -412,7 +412,7 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
expect(activations.count).toBe(1)
|
||||
|
||||
yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
|
||||
@@ -424,7 +424,7 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps awaitActivation pending while later hot reload runs", () =>
|
||||
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -433,7 +433,7 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
@@ -451,20 +451,20 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
expect(activationFiber.pollUnsafe()).toBeUndefined()
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(activationFiber)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Deferred.await(completed)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("does not cancel activation when an awaitActivation waiter is interrupted", () =>
|
||||
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -489,15 +489,15 @@ describe("LocationServiceMap", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
const activationFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Fiber.interrupt(activationFiber)
|
||||
yield* Fiber.interrupt(flushFiber)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(completed)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("500 millis"),
|
||||
)
|
||||
@@ -519,7 +519,7 @@ describe("LocationServiceMap", () => {
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
@@ -546,7 +546,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
@@ -710,7 +710,7 @@ describe("LocationServiceMap", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const registry = yield* Tool.Service
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
@@ -992,7 +992,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
expect(observed.example).toBe(false)
|
||||
yield* mcp.add("dynamic", {
|
||||
type: "remote",
|
||||
|
||||
@@ -111,7 +111,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(Tool.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -95,7 +95,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
)
|
||||
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
|
||||
const locations: Location.Ref[] = []
|
||||
const activationWaits: Location.Ref[] = []
|
||||
const flushes: Location.Ref[] = []
|
||||
const resumes: SessionSchema.ID[] = []
|
||||
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
|
||||
const execution = SessionExecution.Service.of({
|
||||
@@ -143,8 +143,8 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
awaitActivation: Effect.sync(() => {
|
||||
activationWaits.push(ref)
|
||||
flush: Effect.sync(() => {
|
||||
flushes.push(ref)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -175,7 +175,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
}),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, activationWaits, resumes, wakes, db: database.db, bus, store }
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
})
|
||||
|
||||
describe("Session-owned handles", () => {
|
||||
@@ -252,7 +252,7 @@ describe("Session-owned handles", () => {
|
||||
const calls: string[] = []
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
calls.push(event.prompt.text)
|
||||
event.prompt.text += " prepared"
|
||||
}),
|
||||
@@ -277,7 +277,7 @@ describe("Session-owned handles", () => {
|
||||
)
|
||||
expect(calls).toEqual(["Original"])
|
||||
expect(fixture.locations).toEqual([source])
|
||||
expect(fixture.activationWaits).toEqual([source])
|
||||
expect(fixture.flushes).toEqual([source])
|
||||
expect(fixture.wakes).toEqual([
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
|
||||
@@ -363,7 +363,7 @@ describe("Session-owned handles", () => {
|
||||
expect(fixture.locations).toEqual([source])
|
||||
yield* prompt
|
||||
expect(fixture.locations).toEqual([source, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination])
|
||||
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
|
||||
}),
|
||||
)
|
||||
@@ -408,7 +408,7 @@ describe("Session-owned handles", () => {
|
||||
expect(
|
||||
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
|
||||
).toEqual([undefined, undefined, source])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -442,7 +442,7 @@ describe("Session-owned handles", () => {
|
||||
expect(yield* handle.get()).toEqual(before)
|
||||
expect(yield* handle.inbox()).toEqual([])
|
||||
expect(yield* fixture.store.context(sessionID)).toEqual([])
|
||||
expect(fixture.activationWaits).toEqual([])
|
||||
expect(fixture.flushes).toEqual([])
|
||||
expect(fixture.resumes).toEqual([])
|
||||
expect(fixture.wakes).toEqual([])
|
||||
}),
|
||||
@@ -776,7 +776,7 @@ describe("Session-owned handles", () => {
|
||||
|
||||
expect(captures).toEqual([source, destination])
|
||||
expect(fixture.locations).toEqual([source, destination, destination])
|
||||
expect(fixture.activationWaits).toEqual([source, destination, destination])
|
||||
expect(fixture.flushes).toEqual([source, destination, destination])
|
||||
expect((yield* handle.get()).revert).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -800,7 +800,7 @@ describe("SessionPrompt construction", () => {
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(PluginHooks.Service, fixture.hooks),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
awaitActivation: Effect.sync(() => {
|
||||
flush: Effect.sync(() => {
|
||||
calls.push("ready")
|
||||
}),
|
||||
}),
|
||||
@@ -842,8 +842,8 @@ describe("SessionRevert construction", () => {
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
awaitActivation: Effect.sync(() => {
|
||||
calls.push("awaitActivation")
|
||||
flush: Effect.sync(() => {
|
||||
calls.push("flush")
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Snapshot.Service, {
|
||||
@@ -871,7 +871,7 @@ describe("SessionRevert construction", () => {
|
||||
yield* revert
|
||||
.stage({ session, messageID: boundary.id, files: false })
|
||||
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual(["awaitActivation", "capture", "capture", "diff"])
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
|
||||
|
||||
const staged = yield* handle.get()
|
||||
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
|
||||
@@ -879,15 +879,7 @@ describe("SessionRevert construction", () => {
|
||||
const cleared = yield* handle.get()
|
||||
expect(cleared.revert).toBeUndefined()
|
||||
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
|
||||
expect(calls).toEqual([
|
||||
"awaitActivation",
|
||||
"capture",
|
||||
"capture",
|
||||
"diff",
|
||||
"awaitActivation",
|
||||
"restore",
|
||||
"awaitActivation",
|
||||
])
|
||||
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ const setup = Effect.gen(function* () {
|
||||
const services = locations.get(session.location)
|
||||
const hooks = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
return yield* PluginHooks.Service
|
||||
}).pipe(Effect.provide(services))
|
||||
return { sessions, session, hooks, services }
|
||||
|
||||
@@ -115,7 +115,7 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
awaitActivation: Effect.sync(() => (ready = true)),
|
||||
flush: Effect.sync(() => (ready = true)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("Session.revert files", () => {
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
|
||||
@@ -85,10 +85,7 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer()
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ awaitActivation: Effect.void }),
|
||||
)
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
|
||||
@@ -206,7 +206,7 @@ const makeRunnerState = () => {
|
||||
systemUnavailable: false,
|
||||
systemLoadHook: Effect.void,
|
||||
skillBaselines: new Map<Agent.ID, string>(),
|
||||
pluginActivationHook: Effect.void,
|
||||
pluginFlushHook: Effect.void,
|
||||
authorizations: new Array<Tool.Context>(),
|
||||
executions: new Array<string>(),
|
||||
closedTransports: new Array<Session.ID>(),
|
||||
@@ -390,7 +390,7 @@ const layer = Layer.unwrap(
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
awaitActivation: Effect.suspend(() => state.pluginActivationHook),
|
||||
flush: Effect.suspend(() => state.pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
@@ -1840,7 +1840,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("waits for initial plugin readiness before constructing the model request", function* (s) {
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.pluginActivationHook = Deferred.await(release)
|
||||
s.pluginFlushHook = Deferred.await(release)
|
||||
yield* s.session.prompt({ sessionID, text: "Wait for plugins", resume: false })
|
||||
|
||||
s.requests.length = 0
|
||||
|
||||
@@ -53,7 +53,7 @@ const locations = makeGlobalNode({
|
||||
list: () => Effect.succeed([info]),
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
awaitActivation: Effect.void,
|
||||
flush: Effect.void,
|
||||
}),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
),
|
||||
|
||||
@@ -130,7 +130,7 @@ const it = testEffect(
|
||||
Catalog.node.replace(catalog),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { awaitActivation: Effect.void })),
|
||||
PluginSupervisor.node.replace(Layer.mock(PluginSupervisor.Service, { flush: Effect.void })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void }))),
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
@@ -220,7 +220,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
||||
const locationLayer = locations.get(location)
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* plugins.flush
|
||||
const registry = yield* Tool.Service
|
||||
return yield* body(registry)
|
||||
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
||||
|
||||
@@ -107,9 +107,7 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ awaitActivation: Effect.void })),
|
||||
),
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
@@ -157,9 +155,7 @@ const completionIt = testEffect(
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionInboxInfo,
|
||||
ShellInfo,
|
||||
@@ -156,6 +157,22 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
export interface ToolPresentation {
|
||||
/** The status-aware summary shown in the transcript row. */
|
||||
readonly summary: string
|
||||
/** A single-cell icon. OpenCode supplies its status icon when omitted. */
|
||||
readonly icon?: string
|
||||
}
|
||||
|
||||
type ReadonlyDeep<Value> =
|
||||
Value extends ReadonlyArray<infer Item>
|
||||
? ReadonlyArray<ReadonlyDeep<Item>>
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: ReadonlyDeep<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type ToolPresenter = (part: ReadonlyDeep<SessionMessageAssistantTool>) => ToolPresentation | undefined
|
||||
|
||||
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
|
||||
|
||||
/**
|
||||
@@ -465,6 +482,10 @@ export interface UI {
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
readonly tool: {
|
||||
/** Registers a transcript presenter for an exact effective tool name. */
|
||||
register(name: string, presenter: ToolPresenter): () => void
|
||||
}
|
||||
/** Claims a place in the slot tree; see SlotClaim. */
|
||||
readonly slot: (claim: SlotClaim) => () => void
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ for (const selection of ["explicit", "default"] as const) {
|
||||
PluginSupervisor.Service,
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
return { awaitActivation: release.open.pipe(Effect.andThen(plugins.awaitActivation)) }
|
||||
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
|
||||
}),
|
||||
).pipe(Layer.provide(layer)),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
.handle("plugin.update", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const plugins = yield* Plugin.Service
|
||||
if (
|
||||
!(yield* plugins.list()).some(
|
||||
|
||||
@@ -9,7 +9,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
handlers.handle("rpc.call", ({ params, payload }) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
const rpc = yield* Rpc.Service
|
||||
const output = yield* rpc.call(params.rpcID, params.method, payload.input)
|
||||
return output === undefined ? {} : { output }
|
||||
|
||||
@@ -48,7 +48,7 @@ const fixture = Effect.fn(function* (plugins: readonly Plugin.Plugin[]) {
|
||||
boot: (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.awaitActivation
|
||||
yield* supervisor.flush
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })))),
|
||||
call: (
|
||||
route: string,
|
||||
|
||||
@@ -250,7 +250,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => {
|
||||
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
|
||||
])
|
||||
yield* Effect.forEach([primary, secondary], (context) =>
|
||||
PluginSupervisor.Service.use((supervisor) => supervisor.awaitActivation).pipe(Effect.provide(context)),
|
||||
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
|
||||
)
|
||||
expect(activations).toBe(2)
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type {
|
||||
Context,
|
||||
Dialog,
|
||||
Page,
|
||||
SlotClaim,
|
||||
SlotMap,
|
||||
SlotPath,
|
||||
Toast,
|
||||
ToolPresenter,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
@@ -21,6 +30,7 @@ import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
|
||||
@@ -37,17 +47,31 @@ export type RegisteredSlot = {
|
||||
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
|
||||
|
||||
// The provider's registration store, narrowed to what a plugin context needs:
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
// contributions land there, but ordering and lifecycle stay owned by the
|
||||
// provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots" | "markdown", name: string): boolean
|
||||
has(kind: "routes" | "slots" | "markdown" | "tools", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, claim: RegisteredSlot): void
|
||||
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
|
||||
remove(kind: "routes" | "slots" | "markdown", name: string): void
|
||||
set(kind: "tools", name: string, presenter: ToolPresenter): void
|
||||
remove(kind: "routes" | "slots" | "markdown" | "tools", name: string): void
|
||||
active(): boolean
|
||||
}
|
||||
|
||||
export function guardToolPresenter(presenter: ToolPresenter, onError: (error: unknown) => void): ToolPresenter {
|
||||
let failed = false
|
||||
return (part) => {
|
||||
if (failed) return
|
||||
try {
|
||||
return presenter(part)
|
||||
} catch (error) {
|
||||
failed = true
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The host services a plugin context adapts. Collected once by the provider
|
||||
// (hooks must run during component setup) and shared by every activation.
|
||||
export function usePluginHost() {
|
||||
@@ -96,8 +120,8 @@ export function createPluginContext(input: {
|
||||
},
|
||||
}
|
||||
// Unregistering after deactivation is a no-op: deactivate already resets
|
||||
// the registration's routes and slots wholesale.
|
||||
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
|
||||
// the registration's contributions wholesale.
|
||||
const registration = (kind: "routes" | "slots" | "markdown" | "tools", name: string) => {
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -205,6 +229,25 @@ export function createPluginContext(input: {
|
||||
return true
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
register(name, presenter) {
|
||||
const tool = name.trim()
|
||||
if (!tool) throw new Error("Tool name is required")
|
||||
if (input.registry.has("tools", tool)) throw new Error(`Tool presenter already registered: ${tool}`)
|
||||
input.registry.set(
|
||||
"tools",
|
||||
tool,
|
||||
guardToolPresenter(presenter, (error) =>
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: "Plugin",
|
||||
message: `${input.id} crashed in tool presenter ${tool}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return registration("tools", tool)
|
||||
},
|
||||
},
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Page, ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
@@ -53,6 +53,7 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly tool: (name: string) => { readonly plugin: string; readonly presenter: ToolPresenter } | undefined
|
||||
readonly slots: {
|
||||
// A mounted <Slot> instance registers its path; the disposer unregisters.
|
||||
readonly register: (path: string) => () => void
|
||||
@@ -73,6 +74,7 @@ type Registration = {
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, RegisteredSlot>
|
||||
markdown: Record<string, MarkdownCodeBlockRenderer>
|
||||
tools: Record<string, ToolPresenter>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
|
||||
@@ -93,6 +95,16 @@ export function combineMarkdownRenderers(
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function combineToolPresenters(
|
||||
sources: ReadonlyArray<readonly [plugin: string, presenters: Readonly<Record<string, ToolPresenter>>]>,
|
||||
) {
|
||||
const presenters = new Map<string, { readonly plugin: string; readonly presenter: ToolPresenter }>()
|
||||
for (const [plugin, source] of sources) {
|
||||
for (const [name, presenter] of Object.entries(source)) presenters.set(name, { plugin, presenter })
|
||||
}
|
||||
return presenters
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
@@ -131,10 +143,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
),
|
||||
),
|
||||
)
|
||||
const tools = createMemo(() =>
|
||||
combineToolPresenters(
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) =>
|
||||
registration.active ? ([[id, registration.tools]] as const) : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const clearContributions = (id: string) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
setStore("registrations", id, "tools", reconcileStore({}))
|
||||
}
|
||||
|
||||
const activate = async (id: string) => {
|
||||
@@ -154,9 +174,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (
|
||||
kind: "routes" | "slots" | "markdown",
|
||||
kind: "routes" | "slots" | "markdown" | "tools",
|
||||
name: string,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer | ToolPresenter,
|
||||
) => setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
@@ -561,6 +581,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
tool: (name) => tools().get(name),
|
||||
slots: { register: registerSlot, resolved },
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
@@ -641,6 +662,7 @@ function toRegistration(item: Desired): Registration {
|
||||
routes: {},
|
||||
slots: {},
|
||||
markdown: {},
|
||||
tools: {},
|
||||
cleanups: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import type { ToolPresentation, ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
@@ -2683,7 +2684,19 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const plugins = usePlugin()
|
||||
const presenter = createMemo(() => plugins.tool(props.part.name))
|
||||
return [
|
||||
<ToolPartContent part={props.part} presenter={presenter()?.presenter} />,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
function ToolPartContent(props: { part: SessionMessageAssistantTool; presenter?: ToolPresenter }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
const presentation = createMemo(() => props.presenter?.(props.part))
|
||||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
@@ -2747,17 +2760,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
<Match when={display() === "skill"}>
|
||||
<Skill {...toolprops} />
|
||||
</Match>
|
||||
<Match when={presentation()}>{(value) => <GenericTool {...toolprops} presentation={value()} />}</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
return content
|
||||
}
|
||||
|
||||
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||
@@ -2841,25 +2850,28 @@ type ToolProps = {
|
||||
output?: string
|
||||
part: SessionMessageAssistantTool
|
||||
}
|
||||
function GenericTool(props: ToolProps) {
|
||||
function GenericTool(props: ToolProps & { presentation?: ToolPresentation }) {
|
||||
const theme = useTheme()
|
||||
const output = createMemo(() => props.output?.trim() ?? "")
|
||||
const input = createMemo(() => Object.entries(props.input))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const expandable = createMemo(() => input().length > 0 || output().length > 0)
|
||||
const loading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
|
||||
const icon = createMemo(() =>
|
||||
toolPresentationIcon(props.presentation, props.part.state.status === "error" ? "✗" : "✓"),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineTool
|
||||
icon={props.part.state.status === "error" ? "✗" : "✓"}
|
||||
icon={icon()}
|
||||
complete={props.part.state.status === "completed"}
|
||||
pending={props.tool}
|
||||
pending={props.presentation?.summary ?? props.tool}
|
||||
spinner={loading()}
|
||||
part={props.part}
|
||||
onClick={expandable() ? () => setExpanded((value) => !value) : undefined}
|
||||
>
|
||||
{genericToolSummary(props.tool, props.input)}
|
||||
{genericToolSummary(props.tool, props.input, props.presentation)}
|
||||
</InlineTool>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}>
|
||||
@@ -2893,11 +2905,16 @@ function GenericTool(props: ToolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function genericToolSummary(tool: string, input: Record<string, unknown>) {
|
||||
export function genericToolSummary(tool: string, input: Record<string, unknown>, presentation?: ToolPresentation) {
|
||||
if (presentation) return presentation.summary
|
||||
const args = primitiveInputSummary(input).replace(/\s+/g, " ")
|
||||
return `${tool}${args ? ` ${args}` : ""}`
|
||||
}
|
||||
|
||||
export function toolPresentationIcon(presentation: ToolPresentation | undefined, fallback: string) {
|
||||
return presentation?.icon && stringWidth(presentation.icon) === 1 ? presentation.icon : fallback
|
||||
}
|
||||
|
||||
function useToolPermission(part: () => SessionMessageAssistantTool | undefined) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseDiagnostics,
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolPresentationIcon,
|
||||
toolDisplay,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
@@ -213,6 +214,16 @@ describe("TUI inline tool wrapping", () => {
|
||||
"demo_get_weather [city=Tokyo, units=celsius]",
|
||||
)
|
||||
expect(genericToolSummary("demo_refresh", {})).toBe("demo_refresh")
|
||||
expect(
|
||||
genericToolSummary("rename_session", { title: "ignored" }, { summary: "Renamed session to “OpenCode”" }),
|
||||
).toBe("Renamed session to “OpenCode”")
|
||||
})
|
||||
|
||||
test("accepts only single-cell presenter icons", () => {
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "✎" }, "✓")).toBe("✎")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "✅" }, "✓")).toBe("✓")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "" }, "✓")).toBe("✓")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "x\ny" }, "✓")).toBe("✓")
|
||||
})
|
||||
|
||||
test("ignores diagnostics with malformed nested ranges", () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client"
|
||||
import type { ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import { guardToolPresenter } from "../src/plugin/api"
|
||||
import { combineToolPresenters } from "../src/plugin/context"
|
||||
|
||||
test("resolves tool presenters by exact name", () => {
|
||||
const presenter: ToolPresenter = () => ({ summary: "Renamed session" })
|
||||
const combined = combineToolPresenters([["session-tools", { rename_session: presenter }]])
|
||||
|
||||
expect(combined.get("rename_session")).toEqual({ plugin: "session-tools", presenter })
|
||||
expect(combined.get("Rename_Session")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("later tool presenter registrations take precedence", () => {
|
||||
const first: ToolPresenter = () => ({ summary: "first" })
|
||||
const second: ToolPresenter = () => ({ summary: "second" })
|
||||
const combined = combineToolPresenters([
|
||||
["first-plugin", { rename_session: first }],
|
||||
["second-plugin", { rename_session: second }],
|
||||
])
|
||||
|
||||
expect(combined.get("rename_session")).toEqual({ plugin: "second-plugin", presenter: second })
|
||||
})
|
||||
|
||||
test("disables a throwing presenter for its activation", () => {
|
||||
const part = {
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "rename_session",
|
||||
state: { status: "running", input: {}, metadata: {} },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageAssistantTool
|
||||
const errors: unknown[] = []
|
||||
let calls = 0
|
||||
const presenter = guardToolPresenter(
|
||||
() => {
|
||||
calls++
|
||||
throw new Error("boom")
|
||||
},
|
||||
(error) => errors.push(error),
|
||||
)
|
||||
|
||||
expect(presenter(part)).toBeUndefined()
|
||||
expect(presenter(part)).toBeUndefined()
|
||||
expect(calls).toBe(1)
|
||||
expect(errors).toHaveLength(1)
|
||||
})
|
||||
@@ -2,7 +2,8 @@
|
||||
title: "CLI"
|
||||
---
|
||||
|
||||
CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state.
|
||||
CLI plugins extend the terminal with commands, routes, slots, tool presenters, Markdown renderers, notifications, and
|
||||
local state.
|
||||
|
||||
```ts title="src/tui.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
@@ -243,6 +244,38 @@ const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
return unregister
|
||||
```
|
||||
|
||||
## Tool transcripts
|
||||
|
||||
Register a presenter for a custom tool's exact effective name to replace its generic transcript summary. The callback
|
||||
receives the tool part for its current state and runs again as that state changes.
|
||||
|
||||
```ts
|
||||
const unregister = context.ui.tool.register("rename_session", (part) => {
|
||||
if (part.state.status === "streaming" || part.state.status === "running") {
|
||||
return { summary: "Renaming session…" }
|
||||
}
|
||||
if (part.state.status === "error") return { summary: "Could not rename session" }
|
||||
|
||||
const title = part.state.metadata?.title
|
||||
if (typeof title !== "string") return
|
||||
return { summary: `Renamed session to “${title}”` }
|
||||
})
|
||||
return unregister
|
||||
```
|
||||
|
||||
Return `undefined` to use OpenCode's built-in or generic presentation for the current state. A presentation may also set
|
||||
a single-cell `icon`; otherwise OpenCode keeps its status icon. Presenters replace only the summary and icon, so standard
|
||||
error expansion, input/output details, permissions, and attachments continue to work. Keep the callback synchronous and
|
||||
free of side effects because it runs during reactive rendering.
|
||||
|
||||
Tool names are matched exactly without aliases or case folding. When several active plugins register the same name, the
|
||||
later plugin takes precedence. OpenCode's built-in tool presentations remain authoritative for their names. A presenter
|
||||
that throws falls back to the standard presentation, reports the plugin error once, and stays disabled until its plugin
|
||||
is reactivated or reloaded.
|
||||
|
||||
This API customizes the full TUI transcript. It does not change Mini, `opencode run`, exported transcripts, permission
|
||||
prompts, or ACP clients.
|
||||
|
||||
## Commands and keymaps
|
||||
|
||||
Register palette, slash, and keyboard commands in a reactive keymap layer.
|
||||
|
||||
Reference in New Issue
Block a user