mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 06:56:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f38c31493 |
@@ -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,6 +1,15 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type {
|
||||
Context,
|
||||
Dialog,
|
||||
Page,
|
||||
SlotClaim,
|
||||
SlotMap,
|
||||
SlotPath,
|
||||
Toast,
|
||||
ToolPresenter,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
@@ -21,6 +30,7 @@ import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
|
||||
@@ -37,17 +47,31 @@ export type RegisteredSlot = {
|
||||
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
|
||||
|
||||
// The provider's registration store, narrowed to what a plugin context needs:
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
// contributions land there, but ordering and lifecycle stay owned by the
|
||||
// provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots" | "markdown", name: string): boolean
|
||||
has(kind: "routes" | "slots" | "markdown" | "tools", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, claim: RegisteredSlot): void
|
||||
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
|
||||
remove(kind: "routes" | "slots" | "markdown", name: string): void
|
||||
set(kind: "tools", name: string, presenter: ToolPresenter): void
|
||||
remove(kind: "routes" | "slots" | "markdown" | "tools", name: string): void
|
||||
active(): boolean
|
||||
}
|
||||
|
||||
export function guardToolPresenter(presenter: ToolPresenter, onError: (error: unknown) => void): ToolPresenter {
|
||||
let failed = false
|
||||
return (part) => {
|
||||
if (failed) return
|
||||
try {
|
||||
return presenter(part)
|
||||
} catch (error) {
|
||||
failed = true
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The host services a plugin context adapts. Collected once by the provider
|
||||
// (hooks must run during component setup) and shared by every activation.
|
||||
export function usePluginHost() {
|
||||
@@ -96,8 +120,8 @@ export function createPluginContext(input: {
|
||||
},
|
||||
}
|
||||
// Unregistering after deactivation is a no-op: deactivate already resets
|
||||
// the registration's routes and slots wholesale.
|
||||
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
|
||||
// the registration's contributions wholesale.
|
||||
const registration = (kind: "routes" | "slots" | "markdown" | "tools", name: string) => {
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
@@ -205,6 +229,25 @@ export function createPluginContext(input: {
|
||||
return true
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
register(name, presenter) {
|
||||
const tool = name.trim()
|
||||
if (!tool) throw new Error("Tool name is required")
|
||||
if (input.registry.has("tools", tool)) throw new Error(`Tool presenter already registered: ${tool}`)
|
||||
input.registry.set(
|
||||
"tools",
|
||||
tool,
|
||||
guardToolPresenter(presenter, (error) =>
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: "Plugin",
|
||||
message: `${input.id} crashed in tool presenter ${tool}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return registration("tools", tool)
|
||||
},
|
||||
},
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Page, ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
@@ -53,6 +53,7 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly tool: (name: string) => { readonly plugin: string; readonly presenter: ToolPresenter } | undefined
|
||||
readonly slots: {
|
||||
// A mounted <Slot> instance registers its path; the disposer unregisters.
|
||||
readonly register: (path: string) => () => void
|
||||
@@ -73,6 +74,7 @@ type Registration = {
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, RegisteredSlot>
|
||||
markdown: Record<string, MarkdownCodeBlockRenderer>
|
||||
tools: Record<string, ToolPresenter>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
|
||||
@@ -93,6 +95,16 @@ export function combineMarkdownRenderers(
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function combineToolPresenters(
|
||||
sources: ReadonlyArray<readonly [plugin: string, presenters: Readonly<Record<string, ToolPresenter>>]>,
|
||||
) {
|
||||
const presenters = new Map<string, { readonly plugin: string; readonly presenter: ToolPresenter }>()
|
||||
for (const [plugin, source] of sources) {
|
||||
for (const [name, presenter] of Object.entries(source)) presenters.set(name, { plugin, presenter })
|
||||
}
|
||||
return presenters
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
@@ -131,10 +143,18 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
),
|
||||
),
|
||||
)
|
||||
const tools = createMemo(() =>
|
||||
combineToolPresenters(
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) =>
|
||||
registration.active ? ([[id, registration.tools]] as const) : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const clearContributions = (id: string) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "markdown", reconcileStore({}))
|
||||
setStore("registrations", id, "tools", reconcileStore({}))
|
||||
}
|
||||
|
||||
const activate = async (id: string) => {
|
||||
@@ -154,9 +174,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (
|
||||
kind: "routes" | "slots" | "markdown",
|
||||
kind: "routes" | "slots" | "markdown" | "tools",
|
||||
name: string,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer | ToolPresenter,
|
||||
) => setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
@@ -561,6 +581,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
tool: (name) => tools().get(name),
|
||||
slots: { register: registerSlot, resolved },
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
@@ -641,6 +662,7 @@ function toRegistration(item: Desired): Registration {
|
||||
routes: {},
|
||||
slots: {},
|
||||
markdown: {},
|
||||
tools: {},
|
||||
cleanups: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import type { ToolPresentation, ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
@@ -2683,7 +2684,19 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const plugins = usePlugin()
|
||||
const presenter = createMemo(() => plugins.tool(props.part.name))
|
||||
return [
|
||||
<ToolPartContent part={props.part} presenter={presenter()?.presenter} />,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
function ToolPartContent(props: { part: SessionMessageAssistantTool; presenter?: ToolPresenter }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
const presentation = createMemo(() => props.presenter?.(props.part))
|
||||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
@@ -2747,17 +2760,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
<Match when={display() === "skill"}>
|
||||
<Skill {...toolprops} />
|
||||
</Match>
|
||||
<Match when={presentation()}>{(value) => <GenericTool {...toolprops} presentation={value()} />}</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
return content
|
||||
}
|
||||
|
||||
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||
@@ -2841,25 +2850,28 @@ type ToolProps = {
|
||||
output?: string
|
||||
part: SessionMessageAssistantTool
|
||||
}
|
||||
function GenericTool(props: ToolProps) {
|
||||
function GenericTool(props: ToolProps & { presentation?: ToolPresentation }) {
|
||||
const theme = useTheme()
|
||||
const output = createMemo(() => props.output?.trim() ?? "")
|
||||
const input = createMemo(() => Object.entries(props.input))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const expandable = createMemo(() => input().length > 0 || output().length > 0)
|
||||
const loading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
|
||||
const icon = createMemo(() =>
|
||||
toolPresentationIcon(props.presentation, props.part.state.status === "error" ? "✗" : "✓"),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineTool
|
||||
icon={props.part.state.status === "error" ? "✗" : "✓"}
|
||||
icon={icon()}
|
||||
complete={props.part.state.status === "completed"}
|
||||
pending={props.tool}
|
||||
pending={props.presentation?.summary ?? props.tool}
|
||||
spinner={loading()}
|
||||
part={props.part}
|
||||
onClick={expandable() ? () => setExpanded((value) => !value) : undefined}
|
||||
>
|
||||
{genericToolSummary(props.tool, props.input)}
|
||||
{genericToolSummary(props.tool, props.input, props.presentation)}
|
||||
</InlineTool>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}>
|
||||
@@ -2893,11 +2905,16 @@ function GenericTool(props: ToolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function genericToolSummary(tool: string, input: Record<string, unknown>) {
|
||||
export function genericToolSummary(tool: string, input: Record<string, unknown>, presentation?: ToolPresentation) {
|
||||
if (presentation) return presentation.summary
|
||||
const args = primitiveInputSummary(input).replace(/\s+/g, " ")
|
||||
return `${tool}${args ? ` ${args}` : ""}`
|
||||
}
|
||||
|
||||
export function toolPresentationIcon(presentation: ToolPresentation | undefined, fallback: string) {
|
||||
return presentation?.icon && stringWidth(presentation.icon) === 1 ? presentation.icon : fallback
|
||||
}
|
||||
|
||||
function useToolPermission(part: () => SessionMessageAssistantTool | undefined) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseDiagnostics,
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolPresentationIcon,
|
||||
toolDisplay,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
@@ -213,6 +214,16 @@ describe("TUI inline tool wrapping", () => {
|
||||
"demo_get_weather [city=Tokyo, units=celsius]",
|
||||
)
|
||||
expect(genericToolSummary("demo_refresh", {})).toBe("demo_refresh")
|
||||
expect(
|
||||
genericToolSummary("rename_session", { title: "ignored" }, { summary: "Renamed session to “OpenCode”" }),
|
||||
).toBe("Renamed session to “OpenCode”")
|
||||
})
|
||||
|
||||
test("accepts only single-cell presenter icons", () => {
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "✎" }, "✓")).toBe("✎")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "✅" }, "✓")).toBe("✓")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "" }, "✓")).toBe("✓")
|
||||
expect(toolPresentationIcon({ summary: "Renamed", icon: "x\ny" }, "✓")).toBe("✓")
|
||||
})
|
||||
|
||||
test("ignores diagnostics with malformed nested ranges", () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client"
|
||||
import type { ToolPresenter } from "@opencode-ai/plugin/tui/context"
|
||||
import { guardToolPresenter } from "../src/plugin/api"
|
||||
import { combineToolPresenters } from "../src/plugin/context"
|
||||
|
||||
test("resolves tool presenters by exact name", () => {
|
||||
const presenter: ToolPresenter = () => ({ summary: "Renamed session" })
|
||||
const combined = combineToolPresenters([["session-tools", { rename_session: presenter }]])
|
||||
|
||||
expect(combined.get("rename_session")).toEqual({ plugin: "session-tools", presenter })
|
||||
expect(combined.get("Rename_Session")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("later tool presenter registrations take precedence", () => {
|
||||
const first: ToolPresenter = () => ({ summary: "first" })
|
||||
const second: ToolPresenter = () => ({ summary: "second" })
|
||||
const combined = combineToolPresenters([
|
||||
["first-plugin", { rename_session: first }],
|
||||
["second-plugin", { rename_session: second }],
|
||||
])
|
||||
|
||||
expect(combined.get("rename_session")).toEqual({ plugin: "second-plugin", presenter: second })
|
||||
})
|
||||
|
||||
test("disables a throwing presenter for its activation", () => {
|
||||
const part = {
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "rename_session",
|
||||
state: { status: "running", input: {}, metadata: {} },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageAssistantTool
|
||||
const errors: unknown[] = []
|
||||
let calls = 0
|
||||
const presenter = guardToolPresenter(
|
||||
() => {
|
||||
calls++
|
||||
throw new Error("boom")
|
||||
},
|
||||
(error) => errors.push(error),
|
||||
)
|
||||
|
||||
expect(presenter(part)).toBeUndefined()
|
||||
expect(presenter(part)).toBeUndefined()
|
||||
expect(calls).toBe(1)
|
||||
expect(errors).toHaveLength(1)
|
||||
})
|
||||
@@ -2,7 +2,8 @@
|
||||
title: "CLI"
|
||||
---
|
||||
|
||||
CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state.
|
||||
CLI plugins extend the terminal with commands, routes, slots, tool presenters, Markdown renderers, notifications, and
|
||||
local state.
|
||||
|
||||
```ts title="src/tui.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
@@ -243,6 +244,38 @@ const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
return unregister
|
||||
```
|
||||
|
||||
## Tool transcripts
|
||||
|
||||
Register a presenter for a custom tool's exact effective name to replace its generic transcript summary. The callback
|
||||
receives the tool part for its current state and runs again as that state changes.
|
||||
|
||||
```ts
|
||||
const unregister = context.ui.tool.register("rename_session", (part) => {
|
||||
if (part.state.status === "streaming" || part.state.status === "running") {
|
||||
return { summary: "Renaming session…" }
|
||||
}
|
||||
if (part.state.status === "error") return { summary: "Could not rename session" }
|
||||
|
||||
const title = part.state.metadata?.title
|
||||
if (typeof title !== "string") return
|
||||
return { summary: `Renamed session to “${title}”` }
|
||||
})
|
||||
return unregister
|
||||
```
|
||||
|
||||
Return `undefined` to use OpenCode's built-in or generic presentation for the current state. A presentation may also set
|
||||
a single-cell `icon`; otherwise OpenCode keeps its status icon. Presenters replace only the summary and icon, so standard
|
||||
error expansion, input/output details, permissions, and attachments continue to work. Keep the callback synchronous and
|
||||
free of side effects because it runs during reactive rendering.
|
||||
|
||||
Tool names are matched exactly without aliases or case folding. When several active plugins register the same name, the
|
||||
later plugin takes precedence. OpenCode's built-in tool presentations remain authoritative for their names. A presenter
|
||||
that throws falls back to the standard presentation, reports the plugin error once, and stays disabled until its plugin
|
||||
is reactivated or reloaded.
|
||||
|
||||
This API customizes the full TUI transcript. It does not change Mini, `opencode run`, exported transcripts, permission
|
||||
prompts, or ACP clients.
|
||||
|
||||
## Commands and keymaps
|
||||
|
||||
Register palette, slash, and keyboard commands in a reactive keymap layer.
|
||||
|
||||
Reference in New Issue
Block a user