Compare commits

..
11 changed files with 267 additions and 224 deletions
+6 -3
View File
@@ -676,9 +676,12 @@ const layer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
// Plugin activation batches registrations: a plugin resolving during its own
// setup must see the refresh implementation it just registered, or an expired
// credential is silently returned without refreshing.
const current = yield* state.resolve()
const implementation = current.integrations
.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
+25 -2
View File
@@ -74,7 +74,14 @@ export interface Options<State, DraftApi> {
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the last published value without rebuilding or waiting. */
readonly get: () => State
/**
* Resolves completed registration changes, joining an in-progress rebuild or
* materializing batched changes before returning the published value. Does not
* wait for future registrations or a scheduled reload's debounce.
*/
readonly resolve: () => Effect.Effect<State>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -84,11 +91,13 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let requestedAt = 0
let running = false
let closed = false
let dirty = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
dirty = false
if (options.finalize) yield* options.finalize(options.draft(next))
})
@@ -162,6 +171,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
closed = true
return
}
dirty = true
batch.reloads.add(materializeReload)
return
}
@@ -177,12 +187,25 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
if (batch?.active) {
dirty = true
batch.reloads.add(materializeReload)
} else yield* materializeReload()
return { dispose }
}),
)
}),
reload,
resolve: Effect.fnUntraced(
function* () {
const batch = yield* CurrentBatch
if (dirty) yield* materialize()
// Resolution replaces this batch's queued rebuild only after publication succeeds.
batch?.reloads.delete(materializeReload)
return state
},
// Wait interruptibly for the owner, then finish publication and notification together.
(effect) => semaphore.withPermit(Effect.uninterruptible(effect)),
),
}
}
+36
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,41 @@ describe("Integration", () => {
}),
)
it.effect("refreshes an expired OAuth credential registered in the same batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("opencode")
const methodID = Integration.MethodID.make("device")
const stored = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.OAuth.make({ type: "oauth", methodID, access: "expired", refresh: "refresh", expires: 1 }),
})
// Plugin activation batches setup, deferring method registration until
// every plugin finishes. A plugin resolving its connection during setup
// must still reach the refresh implementation it just registered.
const resolved = yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Device" },
authorize: () => Effect.die(new Error("unused authorize")),
refresh: (credential) =>
Effect.succeed({ ...credential, access: "fresh", expires: Number.MAX_SAFE_INTEGER }),
}),
)
return yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: "Work" })
}),
)
expect(resolved).toMatchObject({ type: "oauth", access: "fresh" })
expect((yield* credentials.get(stored.id))?.value).toMatchObject({ access: "fresh" })
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+178 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Scheduler, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
@@ -55,12 +55,18 @@ describe("State", () => {
}),
)
it.effect("runs transforms during every reload", () =>
it.effect("skips a reload's debounce when resolving but joins an in-progress reload", () =>
Effect.gen(function* () {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () =>
value === "first"
? Effect.void
: Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* state.transform((editor) => {
@@ -70,8 +76,15 @@ describe("State", () => {
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
expect((yield* state.resolve()).values).toEqual(["first"])
yield* TestClock.adjust("500 millis")
yield* Deferred.await(rebuilding)
const reader = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
expect(reader.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(reload)
expect((yield* Fiber.join(reader)).values).toEqual(["second"])
expect(state.get().values).toEqual(["second"])
}),
)
@@ -133,6 +146,168 @@ describe("State", () => {
}),
)
it.effect("resolves registrations deferred by a batch without rebuilding twice", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(0)
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => {
draft.add("first")
})
expect(state.get().values).toEqual([])
expect((yield* state.resolve()).values).toEqual(["first"])
expect(state.get().values).toEqual(["first"])
expect(finalized).toBe(1)
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(1)
yield* state.transform((draft) => {
draft.add("second")
})
}),
)
// Resolution absorbed the queued batch rebuild; only the later registration
// rebuilds at batch completion.
expect(state.get().values).toEqual(["first", "second"])
expect(finalized).toBe(2)
}),
)
it.effect("resolves disposals deferred by a batch", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const scope = yield* Scope.make()
yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(state.get().values).toEqual(["value"])
yield* State.batch(
Effect.gen(function* () {
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual(["value"])
expect((yield* state.resolve()).values).toEqual([])
}),
)
}),
)
it.effect("joins a concurrent resolution instead of returning the previous publication", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const values = Array.from({ length: 128 }, (_, index) => index)
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as number[] }),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized++),
})
yield* State.batch(
Effect.gen(function* () {
yield* Effect.forEach(values, (value) =>
state.transform((draft) => {
draft.values.push(value)
if (value === 0) Deferred.doneUnsafe(started, Effect.void)
}),
)
const reader = yield* Deferred.await(started).pipe(
Effect.andThen(
Effect.gen(function* () {
expect(state.get().values).toEqual([])
return yield* state.resolve()
}),
),
Effect.forkChild({ startImmediately: true }),
)
// Yield during synchronous transform replay, before the next value is published.
const writer = yield* state
.resolve()
.pipe(Effect.provideService(Scheduler.MaxOpsBeforeYield, 64), Effect.forkChild({ startImmediately: true }))
const observed = yield* Fiber.join(reader)
const published = yield* Fiber.join(writer)
expect(observed.values).toEqual(values)
expect(observed).toBe(published)
}),
)
expect(finalized).toBe(1)
}),
)
it.effect("keeps queued resolution cancellable but finishes a rebuild once started", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => draft,
finalize: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => finalized++)),
),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.values.push("value"))
const writer = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
yield* Deferred.await(started)
const reader = yield* state.resolve().pipe(Effect.forkChild({ startImmediately: true }))
expect(reader.pollUnsafe()).toBeUndefined()
yield* Fiber.interrupt(reader)
expect(writer.pollUnsafe()).toBeUndefined()
const interruption = yield* Fiber.interrupt(writer).pipe(Effect.forkChild({ startImmediately: true }))
expect(interruption.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interruption)
expect((yield* state.resolve()).values).toEqual(["value"])
}),
)
expect(finalized).toBe(1)
}),
)
it.effect("can resolve again after transform replay fails", () =>
Effect.gen(function* () {
let fail = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => draft,
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => {
if (fail) throw new Error("replay failed")
draft.values.push("value")
})
expect(Exit.isFailure(yield* Effect.exit(state.resolve()))).toBeTrue()
expect(state.get().values).toEqual([])
fail = false
expect((yield* state.resolve()).values).toEqual(["value"])
}),
)
}),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
@@ -160,6 +335,7 @@ describe("State", () => {
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(yield* state.resolve()).toBe(state.get())
expect(finalized).toBe(1)
}),
)
-21
View File
@@ -17,7 +17,6 @@ import type {
ProviderInfo,
ReferenceInfo,
SessionInfo,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionInboxInfo,
ShellInfo,
@@ -157,22 +156,6 @@ 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" }
/**
@@ -482,10 +465,6 @@ 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
}
+7 -50
View File
@@ -1,15 +1,6 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type {
Context,
Dialog,
Page,
SlotClaim,
SlotMap,
SlotPath,
Toast,
ToolPresenter,
} from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
@@ -30,7 +21,6 @@ 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>
@@ -47,31 +37,17 @@ 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:
// contributions land there, but ordering and lifecycle stay owned by the
// provider.
// route/slot registration lands there, but ordering and lifecycle stay owned
// by the provider.
export type Registry = {
has(kind: "routes" | "slots" | "markdown" | "tools", name: string): boolean
has(kind: "routes" | "slots" | "markdown", 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
set(kind: "tools", name: string, presenter: ToolPresenter): void
remove(kind: "routes" | "slots" | "markdown" | "tools", name: string): void
remove(kind: "routes" | "slots" | "markdown", 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() {
@@ -120,8 +96,8 @@ export function createPluginContext(input: {
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
// the registration's contributions wholesale.
const registration = (kind: "routes" | "slots" | "markdown" | "tools", name: string) => {
// the registration's routes and slots wholesale.
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
let registered = true
const unregister = () => {
if (!registered) return
@@ -229,25 +205,6 @@ 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.
+3 -25
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, ToolPresenter } from "@opencode-ai/plugin/tui/context"
import type { Page } 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,7 +53,6 @@ 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
@@ -74,7 +73,6 @@ type Registration = {
routes: Record<string, Page>
slots: Record<string, RegisteredSlot>
markdown: Record<string, MarkdownCodeBlockRenderer>
tools: Record<string, ToolPresenter>
cleanups: Dispose[]
}
@@ -95,16 +93,6 @@ 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()
@@ -143,18 +131,10 @@ 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) => {
@@ -174,9 +154,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
registry: {
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
set: (
kind: "routes" | "slots" | "markdown" | "tools",
kind: "routes" | "slots" | "markdown",
name: string,
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer | ToolPresenter,
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
) => setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
@@ -581,7 +561,6 @@ 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
@@ -662,7 +641,6 @@ function toRegistration(item: Desired): Registration {
routes: {},
slots: {},
markdown: {},
tools: {},
cleanups: [],
}
}
+11 -28
View File
@@ -86,7 +86,6 @@ 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,
@@ -2684,19 +2683,7 @@ 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() {
@@ -2760,13 +2747,17 @@ function ToolPartContent(props: { part: SessionMessageAssistantTool; presenter?:
<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
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
@@ -2850,28 +2841,25 @@ type ToolProps = {
output?: string
part: SessionMessageAssistantTool
}
function GenericTool(props: ToolProps & { presentation?: ToolPresentation }) {
function GenericTool(props: ToolProps) {
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={icon()}
icon={props.part.state.status === "error" ? "✗" : "✓"}
complete={props.part.state.status === "completed"}
pending={props.presentation?.summary ?? props.tool}
pending={props.tool}
spinner={loading()}
part={props.part}
onClick={expandable() ? () => setExpanded((value) => !value) : undefined}
>
{genericToolSummary(props.tool, props.input, props.presentation)}
{genericToolSummary(props.tool, props.input)}
</InlineTool>
<Show when={expanded()}>
<box paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}>
@@ -2905,16 +2893,11 @@ function GenericTool(props: ToolProps & { presentation?: ToolPresentation }) {
)
}
export function genericToolSummary(tool: string, input: Record<string, unknown>, presentation?: ToolPresentation) {
if (presentation) return presentation.summary
export function genericToolSummary(tool: string, input: Record<string, unknown>) {
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,7 +10,6 @@ import {
parseDiagnostics,
parseQuestionAnswers,
parseQuestions,
toolPresentationIcon,
toolDisplay,
} from "../../../src/routes/session"
@@ -214,16 +213,6 @@ 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
@@ -1,48 +0,0 @@
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,8 +2,7 @@
title: "CLI"
---
CLI plugins extend the terminal with commands, routes, slots, tool presenters, Markdown renderers, notifications, and
local state.
CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state.
```ts title="src/tui.ts"
import { Plugin } from "@opencode-ai/plugin/tui"
@@ -244,38 +243,6 @@ 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.