mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 06:56:21 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911a3998de | ||
|
|
b9cd9e3e3c |
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user