Compare commits

..
Author SHA1 Message Date
R44VC0RP 1f38c31493 feat(tui): add plugin tool presenters 2026-09-01 14:18:48 +00:00
39 changed files with 304 additions and 126 deletions
@@ -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") {}
+2 -2
View File
@@ -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 })
}),
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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 })
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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")(() =>
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 }),
),
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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")),
+2 -2
View File
@@ -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()
+29 -29
View File
@@ -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",
+1 -1
View File
@@ -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({
+15 -23
View File
@@ -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 }
+1 -1
View File
@@ -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)),
}),
),
),
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 }),
),
+1 -1
View File
@@ -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 })),
],
),
)
+2 -2
View File
@@ -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)))
+2 -6
View File
@@ -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.
+21
View File
@@ -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
}
+1 -1
View File
@@ -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)),
)
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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 }
+1 -1
View File
@@ -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)
+50 -7
View File
@@ -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.
+25 -3
View File
@@ -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: [],
}
}
+28 -11
View File
@@ -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", () => {
+48
View File
@@ -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.