CLI 插件通过命令、路由、插槽、Markdown 渲染器、通知和本地状态扩展终端。
import { Plugin } from "@opencode/plugin/tui"
export default Plugin.define({
id: "acme.cli",
setup(context) {
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
},
})
直接导入
@opencode/plugin/tui。OpenCode 会在运行时解析该导入,因此本地 CLI 插件不需要指向 OpenCode 检出目录的绝对路径。上下文
setup 接收配置、应用元数据、当前位置、OpenCode 客户端、缓存数据、主题令牌、OpenTUI 渲染器,以及下文所述的 UI API。setup(context) {
const compact = context.options.compact === true
const location = context.location ?? context.data.location.default()
const version = context.app.version
const channel = context.app.channel
const client = context.client
const renderer = context.renderer
const theme = context.theme
}
为插件所拥有的资源返回一个清理函数。
setup(context) {
const stop = context.data.on("session.execution.succeeded", () => {})
return () => stop()
}
客户端
context.client 是生成的 OpenCode 客户端,可以调用已连接的服务器,包括远程服务器。const response = await context.client.plugin.list({
location: context.location ?? context.data.location.default(),
})
const plugins = response.data
事件
使用
data.on 处理单个类型化事件,或使用 data.listen 处理所有服务器事件;两者都会返回一个取消订阅函数。const stopPermission = context.data.on("permission.asked", (event) => {
context.ui.toast.show({ message: `Permission ${event.data.id}` })
})
const stopAll = context.data.listen(({ details }) => console.log(details.type))
return () => {
stopPermission()
stopAll()
}
会话
会话数据提供列表、查找、层级、成本、状态、同步和失效操作。
const sessions = context.data.session.list()
const session = context.data.session.get(sessionID)
const rootID = context.data.session.root(sessionID)
const familyIDs = context.data.session.family(sessionID)
const cost = context.data.session.cost(sessionID)
const status = context.data.session.status(sessionID)
await context.data.session.sync(sessionID)
context.data.session.invalidate(sessionID)
待处理的收件箱条目和消息提供列表、查找、同步和失效 API。
await context.data.session.pending.sync(sessionID)
const pending = context.data.session.pending.list(sessionID)
context.data.session.pending.invalidate(sessionID)
await context.data.session.message.sync(sessionID)
const messages = context.data.session.message.list(sessionID)
const message = context.data.session.message.get(sessionID, messageID)
context.data.session.message.invalidate(sessionID)
权限请求可以针对会话进行读取和刷新。
await context.data.session.permission.sync(sessionID)
const requests = context.data.session.permission.list(sessionID) ?? []
context.data.session.permission.invalidate(sessionID)
表单可以在某个位置列出、刷新、回复或取消。
import type { SessionFormCancelInput, SessionFormReplyInput } from "@opencode/client"
async function handleForm(reply: SessionFormReplyInput, cancel: SessionFormCancelInput) {
const location = context.location
await context.data.session.form.sync(sessionID, location)
const forms = context.data.session.form.list(sessionID, location) ?? []
await context.data.session.form.reply(reply, location)
await context.data.session.form.cancel(cancel, location)
context.data.session.form.invalidate(sessionID, location)
}
项目与 shell
项目和已保存的权限支持列表、查找、同步和失效操作。
await context.data.project.sync()
const projects = context.data.project.list()
const project = context.data.project.get(projectID)
context.data.project.invalidate()
await context.data.project.permission.sync(projectID)
const saved = context.data.project.permission.list(projectID) ?? []
context.data.project.permission.invalidate(projectID)
shell 数据支持按位置限定的列表、查找、同步和失效操作。
await context.data.shell.sync(context.location)
const shells = context.data.shell.list(context.location)
const shell = context.data.shell.get(shellID)
context.data.shell.invalidate(context.location)
位置数据
位置状态提供默认位置和刷新控制。
const location = context.data.location.default()
await context.data.location.sync(location)
context.data.location.invalidate(location)
版本控制状态提供某个位置的仓库信息。
await context.data.location.vcs.sync(context.location)
const vcs = context.data.location.vcs.info(context.location)
const branch = vcs?.branch.current
context.data.location.vcs.invalidate(context.location)
智能体、命令、集成、模型、提供商、引用、技能和 MCP 数据共享
list、sync 与 invalidate 方法。const location = context.location
await Promise.all([
context.data.location.agent.sync(location),
context.data.location.command.sync(location),
context.data.location.integration.sync(location),
context.data.location.model.sync(location),
context.data.location.provider.sync(location),
context.data.location.reference.sync(location),
context.data.location.skill.sync(location),
context.data.location.mcp.server.sync(location),
context.data.location.mcp.resource.sync(location),
])
const agents = context.data.location.agent.list(location) ?? []
const commands = context.data.location.command.list(location) ?? []
const integrations = context.data.location.integration.list(location) ?? []
const models = context.data.location.model.list(location) ?? []
const providers = context.data.location.provider.list(location) ?? []
const references = context.data.location.reference.list(location) ?? []
const skills = context.data.location.skill.list(location) ?? []
const servers = context.data.location.mcp.server.list(location) ?? []
const resources = context.data.location.mcp.resource.list(location) ?? []
context.data.location.model.invalidate(location)
提醒
提醒请求可以显示系统通知、播放已配置的声音,或根据终端焦点状态同时执行两者。
const result = await context.attention.notify({
title: "OpenCode",
message: "Session done",
notification: { when: "blurred" },
sound: { name: "done", volume: 0.5, when: "always" },
})
console.log(result.ok, result.notification, result.sound, result.skipped)
主题与渲染器
在 OpenTUI 元素上使用语义化主题令牌,并将
context.renderer 传递给渲染器相关的辅助函数。const Status = () => <text fg={context.theme.text.base}>Ready</text>
const renderer = context.renderer
渲染器绘制的 OpenTUI 资源需要以
context.renderer.nativeScene 作为其所有者。例如,<markdown> 和 <code> 需要一个 SyntaxStyle。在渲染器空闲时,即绘制它的最后一帧之后销毁该资源。import { SyntaxStyle } from "@opentui/core"
const style = SyntaxStyle.fromStyles({ default: { fg: context.theme.text.base } }, context.renderer.nativeScene)
context.ui.slot({ append: "home.footer", render: () => <markdown content="Ready" syntaxStyle={style} /> })
return () => context.renderer.idle().then(() => style.destroy())
要读取可渲染元素的布局样式,请调用与 Yoga 节点 getter 同名的方法,例如
box.getPadding(Yoga.Edge.Bottom) 或 box.getMaxWidth()。宿主可渲染元素不属于插件 API 的一部分。它们的结构和样式可能在任何版本中发生变化。针对 OpenTUI 0.5 构建的插件
OpenCode 使用其自带的 OpenTUI 运行插件。自 OpenTUI 0.6 起:
SyntaxStyle.create()、SyntaxStyle.fromStyles()、SyntaxStyle.fromTheme()、TextBuffer.create()和EditBuffer.create()以所有者作为最后一个参数。OptimizedBuffer.create()通过owner选项接收它。传入context.renderer.nativeScene。OpenTUI 0.5 会忽略这个额外参数,因此插件仍可在较早的 OpenCode 版本上运行。@opencode/theme/tui中的generateSyntax()以所有者作为第二个参数:generateSyntax(context.theme, context.renderer.nativeScene)。较早的@opencode/theme版本会在没有所有者的情况下构建样式,因此也需要更新该包。getLayoutNode()已被移除。在 OpenTUI 0.6.5 或更高版本上,将getLayoutNode().getMaxWidth()及其他样式 getter 替换为同名的可渲染元素方法。将getComputedLayout()及其他计算位置和尺寸的 getter 替换为getLayout(),将markDirty()替换为invalidateIntrinsicSize()。计算得出的 padding、margin 和 border 没有替代方法,且getPadding(edge)只读取为该边设置的 padding。要还原某个 getter 报告为未设置(Unit.Undefined)的样式,请在 OpenTUI 0.6.6 或更高版本上将undefined赋给该属性。
OpenTUI 0.6.0 发行说明列出了所有变更。
Solid 组件
在路由、对话框或插槽渲染的 JSX 中,使用
usePlugin 访问当前上下文。import { usePlugin } from "@opencode/plugin/tui"
function Status() {
const context = usePlugin()
return <text fg={context.theme.text.base}>{context.app.version}</text>
}
Markdown
按语言注册围栏代码渲染器;返回的函数用于注销它。
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
return unregister
命令与键位映射
在响应式键位映射层中注册命令面板、斜杠命令和键盘命令。
context.keymap.layer(() => ({
mode: "global",
priority: 10,
commands: [
{
id: "acme.status",
title: "Show Acme status",
group: "Acme",
bind: "ctrl+g",
palette: true,
slash: { name: "acme", aliases: ["status"], arguments: true },
enabled: () => true,
suggested: true,
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
},
],
bindings: ["acme.status"],
}))
在
setup 中调用 layer(包括在 await 之后),或在组件中调用。每一层会在以下最先发生的情况中被移除:- 插件被禁用、重新加载或 setup 失败。
- 创建该层的组件或响应式计算(例如插槽、路由或对话框)被销毁。
层工厂必须是纯函数。OpenCode 会先在不追踪依赖的情况下求值一次,以验证初始命令结构,然后进行响应式求值。初始命令结构错误(例如缺少 ID 的命令面板命令)会从
layer() 抛出。OpenTUI 处理绑定解析错误。后续响应式更新产生的错误遵循 Solid 的错误处理方式。async setup(context) {
const settings = await loadSettings()
context.keymap.layer(() => ({
mode: "global",
commands: [{ id: "acme.sync", title: "Sync Acme", palette: true, run: () => sync(settings) }],
}))
}
一个层可以指向单个 OpenTUI 可渲染元素,并且命令可以返回
false 以继续键盘分发。context.keymap.layer(() => ({
target: () => panel,
commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
}))
分发命令、检查快捷键和命令状态,或压入临时输入模式。
context.keymap.dispatch("acme.status", "verbose")
const shortcuts = context.keymap.shortcuts("acme.status")
const commands = context.keymap.commands()
const pending = context.keymap.pending()
const active = context.keymap.active()
const currentMode = context.keymap.mode.current()
const popMode = context.keymap.mode.push("acme-search")
popMode()
存储
持久化存储会跨重启保存 JSON,并在多个 TUI 实例之间同步。
const [settings, updateSettings] = context.storage.store("settings", {
initial: { compact: false },
})
await updateSettings((draft) => {
draft.compact = true
})
内存存储可在插件重新加载后保留,但在 TUI 退出时被丢弃。
const [state, updateState] = context.storage.memory("state", {
initial: { count: 0 },
})
updateState((draft) => {
draft.count++
})
对话框与 toast
使用基于 Promise 的对话框进行警告、确认、文本输入和选择。
await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
const confirmed = await context.ui.dialog.confirm({
title: "Continue?",
message: "Run the Acme action?",
label: { confirm: "Run", cancel: "Cancel" },
})
const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
const mode = await context.ui.dialog.select({
title: "Mode",
current: "safe",
options: [
{ title: "Safe", value: "safe", description: "Ask before changes" },
{ title: "Fast", value: "fast", disabled: false, category: "Advanced" },
],
})
选择搜索
传入
search 以自定义匹配和结果排序。它会对每个查询运行(包括空查询),且只接收已启用的选项。结果按返回顺序列出,每个分类内联显示。const project = await context.ui.dialog.select({
title: "Projects",
options: [
{ title: "Alpha", value: "alpha", category: "Recent" },
{ title: "Beta", value: "beta", category: "Pinned" },
],
search: (query, options) =>
options
.filter((option) => option.title.toLowerCase().includes(query.toLowerCase()))
.toSorted((left, right) => right.title.localeCompare(left.title)),
})
选择操作
操作显示在页脚中,可通过其键盘绑定、点击,或 Tab 聚焦后按 Enter 触发。默认情况下,它们接收选中的值,且在没有选中项时不可用。使用
selection: "none" 表示无需选中项的操作。await context.ui.dialog.select({
title: "Projects",
options: [{ title: "Alpha", value: "alpha" }],
actions: [
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
],
})
- 操作不会关闭对话框,也不会 settle 该 Promise。
- 按 Enter 或点击某个选项会以选中的值 resolve。
- 按 Escape、调用
clear()或显示另一个对话框会 resolve 为undefined。 - 不要绑定
ctrl+n或ctrl+p。对话框将它们保留用于导航,且其自身的绑定优先。
自定义 JSX 对话框可以设置自身尺寸并自行关闭。
context.ui.dialog.set({ size: "large", centered: true })
context.ui.dialog.show(
() => (
<box>
<text>Acme</text>
</box>
),
() => console.log("closed"),
)
context.ui.dialog.clear()
toast 支持标题、消息、变体和持续时间。
context.ui.toast.show({
title: "Acme",
message: "Saved",
variant: "success",
duration: 3000,
})
路由与标签页
注册 JSX 路由、检查当前路由,并导航到主页、某个会话或插件页面。
const unregister = context.ui.router.register({
name: "dashboard",
render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
})
const current = context.ui.router.current()
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
context.ui.router.navigate({ type: "session", sessionID })
context.ui.router.navigate({ type: "home" })
return unregister
当启用会话标签页时,可以列出、打开、聚焦、移动和关闭标签页。
open 不改变焦点;focus 会在需要时打开标签页。if (context.ui.tabs.enabled()) {
context.ui.tabs.open(backgroundSessionID)
context.ui.tabs.focus(sessionID)
const tabs = context.ui.tabs.list()
context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
context.ui.tabs.close(sessionID)
context.ui.tabs.close()
}
模型
读取提示词所选的模型并更改其变体。
current() 和 variant.list() 在 Solid 计算中读取时是响应式的。variant 为 undefined 表示模型默认值。const selected = context.ui.model.current()
const variants = context.ui.model.variant.list()
context.ui.model.variant.set("high")
context.ui.model.variant.set(undefined)
当未选择模型或该模型没有此变体时,
variant.set 返回 false。将其绑定到命令以替换内置的变体循环,例如跳过模型默认值:context.keymap.layer(() => ({
commands: [
{
id: "acme.variant.cycle",
title: "Cycle variants",
bind: "tab",
run() {
const variants = context.ui.model.variant.list()
if (variants.length === 0) return
const index = variants.indexOf(context.ui.model.current()?.variant ?? "")
context.ui.model.variant.set(variants[(index + 1) % variants.length])
},
},
],
}))
插槽
插槽可在
app、home.footer、home.footer.status、prompt.footer、prompt.footer.status、prompt.footer.file、session.composer.top、sidebar.content 或 sidebar.footer 处插入或替换 JSX。return context.ui.slot({
append: "sidebar.content",
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})
使用
prepend、append、before、after 或 replace 指定位置。context.ui.slot({ prepend: "home.footer", render: () => <text>Before footer content</text> })
context.ui.slot({ append: "home.footer", render: () => <text>After footer content</text> })
context.ui.slot({ before: "home.footer", render: () => <text>Before footer slot</text> })
context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</text> })
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
使用
home.footer.status 可在不替换内置页脚行的前提下向其添加内容。状态贡献会出现在 OpenCode 的健康指示器之后、版本号之前。context.ui.slot({ append: "home.footer.status", render: () => <text>SYNCED</text> })
会话面板
注册对
session.panel 的贡献,然后通过命令打开面板。宿主负责尺寸、焦点和全屏展示;插件负责其内容。所选名称会作为 panel.name 传递给每个贡献,由每个贡献自行决定是否渲染。import { Show } from "solid-js"
context.ui.slot({
append: "session.panel",
render: (panel) => (
<Show when={panel.name === "acme.review"}>
<ReviewPanel panel={panel} />
</Show>
),
})
context.ui.slot({
append: "app",
render: () => {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "acme.review",
title: "Open review",
slash: { name: "review" },
run: () => {
context.ui.panel.open("acme.review")
},
},
],
}))
return null
},
})
- 在会话之外打开会返回
false。 - 这是一个普通插槽:全部五种位置以及既有的替换排序规则均适用。
- 对于可独立选择的贡献,使用
append以便它们可以共存。replace仍会接管该插槽。 - 名称是共享的选择值,而非已注册的所有权声明。使用带插件前缀的名称(如
acme.review)以避免冲突。打开一个没有匹配渲染器的名称会使插槽为空。 - 切换展示方式会保留已挂载的贡献。关闭面板会销毁它们;禁用插件会通过正常的插槽清理移除其贡献。
- 其键盘层和输入模式仅在面板拥有输入时处于激活状态。
插槽会接收到响应式的
name、sessionID、width、presentation 和 focused 属性,以及 focus、close 和 toggleFullscreen 操作。宿主在窄终端上保持全屏;在有足够空间容纳侧边面板之前,toggleFullscreen 不起作用。import type { PanelInput } from "@opencode/plugin/tui/context"
import { usePlugin } from "@opencode/plugin/tui"
function ReviewPanel(props: { panel: PanelInput }) {
const context = usePlugin()
context.keymap.layer(() => ({
commands: [
{
id: "acme.review.fullscreen",
bind: "f",
run: props.panel.toggleFullscreen,
},
],
}))
return <text>Reviewing {props.panel.sessionID}</text>
}
你可以请求以全屏方式初始展示、检查当前活动的面板,或在不影响其他插件面板的情况下关闭它。
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()
格式化
格式化文件系统路径以便显示,包括主目录缩写。
const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
发布与加载
通过
./tui 暴露 CLI 插件;当插件渲染 JSX 时,添加 OpenTUI 对等依赖。{
"name": "opencode-acme-plugin",
"type": "module",
"exports": {
".": "./src/index.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
},
"peerDependencies": {
"@opentui/core": ">=0.6.6",
"@opentui/solid": ">=0.6.6",
"solid-js": ">=1.9.0"
}
}
在主插件旁边导出
./tui 以实现自动加载。import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "acme.server",
setup() {},
})
在
cli.json 中配置仅 CLI 的包,使其在远程服务器上保持激活。{
"plugins": ["opencode-acme-plugin"]
}