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"]
}