Compare commits

...
Author SHA1 Message Date
R44VC0RP 1f38c31493 feat(tui): add plugin tool presenters 2026-09-01 14:18:48 +00:00
7 changed files with 217 additions and 22 deletions
+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
}
+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.