插件 API
四个 Plugin 视图
Martty 用四个本地视图区分 contribution、静态加载状态与 Cordis 临时运行状态:
| 视图 | 展示内容 | 生命周期 |
|---|---|---|
/ui |
当前可选的 UI contribution | owner 可以是 Client-only 或双向 Package |
/theme |
当前可选的 Theme contribution | owner 可以是 Client-only 或双向 Package;切换时可联动 owner 启停 |
/plugins |
当前已经加载的静态 Plugin | 只读;不根据条目推断 Host-only、Client-only 或双向 |
/cordis-plugins |
Cordis 模式刚创建的临时动态 Plugin | 跟随当前 run,可被停止、替换或回收 |
/ui 与 /theme 是 Plugin 视图,不是 Package 类型。一个动态双向 Package 的
code.client 可以注册 UI 或 Theme contribution,同时由同一 run 的 code.host
提供 Host 能力。/cordis-plugins 管理的是这个完整 run,而不是只管理其中一半。
Cordis Plugin Package 有两个可选部分:
Cordis Plugin Package
├── code.host # 可选,运行在 Host Cordis tree
└── code.client # 可选,运行在 Martty Client Cordis tree
只包含 code.host 是 Host-only,只包含 code.client 是 Client-only,两者都有就是
双向 Package。双向 Package 仍是一个逻辑 Plugin 和一次启动意图;Client half 通过
host.call(method, args) 调用同一 run 的 Host half。
第三方只通过这一页上的 API 扩展 TUI。没有列出的对象、fd、方法,宿主不提供。实现时做成调不到,而不是写在注释里请人别碰。
当前开放: 配色包、根级右栏 chrome.right、composer 顶沿及内部的
conversation.input.dock、位于 composer 内部、输入区与模式行之间的 conversation.navigation.dock、
最外层的 conversation.composer.dock、本地命令/语义 overlay,以及标准 ACP 当前
Session 的配置、Plan、统计与运行状态目录。四个 Slot 都是
可叠加的 list seat,接收结构化 TuiNode 树,不进入会话日志。
当前可调用:acpSessionConfig
动态 Client 插件注入 acpSessionConfig 后,可以读取当前 ACP Session 原样发布的
configOptions,不需要查询模型源码或猜选项值:
export const inject = ['acpSessionConfig']
export function apply(ctx) {
const options = ctx.acpSessionConfig.list()
const current = ctx.acpSessionConfig.current('some-option-id')
return ctx.acpSessionConfig.subscribe((snapshot) => {
// snapshot = { sessionId, options }
})
}
list() 返回完整 option 描述的深拷贝;current(id) 返回该 option 的
currentValue。set(id, value) 接受标准 ACP 当前支持的字符串 value id 或 boolean,
并校验 option 类型及 select 候选。设置仍由 Rust ACP Client 发标准
session/set_config_option;response-only 的完整 configOptions 也会同时折回 Rust
和 Client 服务,不依赖 Agent 额外发送 notification。subscribe(listener) 的 disposer
归 Plugin run 生命周期所有,停止动态插件后不会继续收更新。
这是任意 ACP option 的目录/状态服务,不认识 effort、模型或具体皮肤;插件从
list() 中选择它需要联动的 option id。
当前可调用:acpSessionPlan
acpSessionPlan 从标准 ACP session/update 的 plan、plan_update 与
plan_removed 折叠结构化状态,提供 list()、current() 和
subscribe(listener)。插件无需解析 raw ACP,也不依赖 DSH 私有 Plan 消息。
内置 plan-view Client Plugin 正是普通消费者:它占用
conversation.input.dock 显示当前
进度摘要,并注册本地 /plan-view,通过 tuiOverlay.openView() 打开审阅清单——
条目以单条 markdown 任务列表节点呈现(## Plan · 完成/总数 标题、
- [x]/- [ ] 状态框、进行中使用 ◐ 图标并标注 in progress(dock 使用动态运行标记)、取消/失败删除线、priority 后缀),
由转录的 markdown 管线完整渲染;宽终端上审阅面板可扩到 2/3 屏宽。
/plan 仍由 agent 的标准 command / mode 语义处理,两者不冲突。
当前可调用:acpSessionStats
acpSessionStats 从标准 ACP prompt response usage、文本/思考首 token、tool call 与
resume usage 更新折叠当前 Session 的 token、cache、turn、step、LLM、tool、TTFT 与
吞吐统计,提供 current() 和 subscribe(listener)。内置 stats-view Client Plugin
是它的默认消费者,向 conversation.composer.dock 注入紧凑统计行;Rust 壳不再
直接拥有这块业务 UI。动态插件也可以注入同一服务并选择自己的呈现方式。
当前可调用:acpSessionStatus
acpSessionStatus 从标准 ACP initialize / authenticate / session /
session/prompt response / session/update 流量,以及可选的 Martty
session.status / session.event 扩展,折叠 /status 需要的非统计运行状态:
state(idle/starting/running)、connection、server、auth、session 绑定/是否已开始、model、
effort、permission、plan 与 agent preset,提供 current() 和
subscribe(listener)。它不累计任何 token 或耗时——统计只有
acpSessionStats 这一套口径。
内置 status-view Client Plugin 正是普通消费者:它注册本地 /status 命令,用
tuiOverlay.openView() 打开一个 markdown 状态视图——运行状态事实取自
acpSessionStatus.current(),token/turn/step/LLM/tool/TTFT/rate 全部取自
acpSessionStats.current()(与 composer dock 里的 stats-view 同一快照来源,
两处读数不会漂移)。Rust painter 只渲染序列化后的 TuiNode,不读自己的
transcript accumulator。没有 Client 树的运行(demo、独立 painter)由 Rust 的
精简 fallback 只画运行状态与 ACP 事实,同样不读 token/耗时累计器。
内置 Queue 投影
follow-up FIFO 是 Rust Client 的交互状态,不是 ACP Session 内容,也不作为第三方服务
开放。painter 只把 { count, items, selectedId, editingId, deleteConfirm } 快照送到本地
Client compositor;内置 tuiQueue service 管理订阅,内置 queue-view Client Plugin
把它注册进 conversation.input.dock。因此排队消息不会先进入 timeline,也不会由 Rust
硬编码一份正常态业务 UI:composer 顶沿显示标题,所有等待条目始终在同一个圆角
composer 内可见;选择/编辑态只增加焦点与操作提示。没有 Client tree 时,原生 shelf 仅作为降级路径。
composer 为空时,标题常驻提示 enter send first;Enter 会把队首立即 steer 给
当前轮次,agent deferred 时原条目仍回到队首。
内置 Agent 导航投影
主会话与 subagent 列表同样属于 Rust Client 的本地交互状态。painter 把
{ activeId, selectedId, items: [{ id, label, kind, status }] } 快照送到内置 tuiAgents service;
内置 agents-view Client Plugin 把它注册到 conversation.navigation.dock,并注册
/agents 命令。默认只显示淡色的 · Agents · 完成/总数 折叠摘要;↓ 后在原地展开
主会话与全部 subagent,←/→ 移动、Enter 打开、Esc 折叠;整个过程不创建
overlay 或鼠标 action。
没有 Client tree 时由 Rust 原生导航降级显示。
Agent 导航没有引入业务专用颜色 token。generic.tone 只接受既有的通用 token 名;
折叠态标签使用 caption,运行中的 完成/总数 在 brand_soft 与 brand 间轮换,
失败态使用 err,其余沿用 fg、panel 与 border。Theme Plugin 仍只维护一套
小而通用的 token 系统。Plan 摘要使用相同的视觉语法。
UI Plugin 与两个欢迎页 slot
tuiPresets 把多个独立 UI Plugin contribution 组合成一个可持久化选择;它与
组合 Host/Agent 能力的 Agent Preset 分属 Client/Host 两棵树,也不是 Theme 的别名。
ctx.tuiPresets.register({ id, label }, mount) 的 mount 只组合结构性 UI
contribution(slot、chrome、pet 等),并返回统一 disposer;它不注册、选择或生成
Theme。UI Plugin、Theme Plugin 与 dark/light 是三个独立状态维度。/ui 打开原生单选表单,/ui 复用
composer 上方的 slash 菜单列出候选,也可用 /ui <id> 直接切换;选择写入
$MARTTY_HOME/settings.json,重启后恢复。Creator 可以 inspect UiPresets 与相邻 Client
服务,生成调用 tuiPresets.register 的 code.client Package;cordis_define/run
只做当前进程预览,成功后必须由 tui_plugin_save 写入
$MARTTY_HOME/plugins/<artifact-id>/plugin.json,新 artifact 使用 kind: "ui",重启后恢复。
旧 kind: "ui-preset" 会在读取边界归一成 ui;UiPresets、tuiPresets 与
settings 的 uiPreset 仅作为兼容性内部名保留。
内置 default(Martty)和 deepseek 两个 UI Plugin 都装配两个 single root slot:
welcome.hero:居中的品牌区,结构为logo + hint;welcome.info:左下的版本、模型、workspace、session、凭据、访问说明与帮助区。
两个 preset 当前复用原生动态 info renderer,但它已经是可独立替换的 slot。
deepseek-logo 仅注册 deepseek preset,不再拥有专用 slash command;使用
/ui deepseek 和 /ui default 切换。Rust painter 复用旧版响应式鲸鱼/wordmark
primitive,并负责水平居中、整个欢迎块垂直居中与上下等距。切换不进入 ACP prompt
或 transcript。
当前可调用:tuiTheme
palette 必须符合 tui-palette.v0.schema.json。
| 字段 | 规则 |
|---|---|
id |
稳定字符串,与已有 id 冲突则 register 失败 |
label |
给 /theme 看的短名 |
dark / light |
各一份 完整 token → #RRGGBB。缺任意 token、或多了不认识的名字,都失败 |
Token 名封闭:
bg surface panel fg fg_secondary fg_tertiary caption brand brand_soft bubble_bg bubble_fg border code_bg ok warn err hint chip_bg
内置 default 仍是现在的冷蓝灰皮,永远不能替换或删除。/theme toggle 与
ctrl+t 只在当前主题的 dark/light 之间切;/theme 的上拉候选将 toggle 与
Theme Plugin 分区显示。/theme <id> 是一个特殊的
Theme Plugin 单选开关:选择一个 id
会启动它所属的 Client Plugin,并停止此前选中的 Theme Plugin;选择 default 会
停止当前动态 Theme Plugin。显式选择的 theme id 与 uiPreset 并列写入
$MARTTY_HOME/settings.json;临时 Creator 预览和自动回退不会改写用户选择。
一个 Theme Plugin 可以同时注册 palette、command、overlay、slot、timer、事务和
Package 内 RPC。它们不写主题条件,也不订阅 active theme;它们只是同一个 Cordis
Fiber 的普通贡献。/theme 切换、stop、update 或 unload 时,整个 Fiber 一起撤销。
已停止 Theme Plugin 的 id 仍留在 /theme 目录里,供以后重新启动。
register(palette, { activate: true }) 是动态插件首次 cordis_run 的即时预览入口。
Client runner 会把这次运行放进同一个 /theme 席位,并替换此前的 Theme Plugin;
目标启动失败则保留原插件。动态 Client facade 只开放 register,不开放
activate()、active() 或 theme subscribe(),避免绕过这个插件开关。
这是在改 TUI:composer、状态栏、边框、气泡、代码块、工具卡、提示行全部改色。布局、框线字符、字号、kitty 宠物像素图不变。
Theme Plugin 仍是普通 Cordis Plugin:在一个 apply 里向各独立服务注册贡献,
disposers 归同一个 Fiber。/theme 只是在这些 Plugin 之间做特殊的单选切换。
Cordis 模式通过 Client inspect 看见这套 API:cordis_inspect_list 里的 Theme
(platform client)来自 TUI 经 ACP 同步的目录,不是 agent 树上的 tuiTheme。
code.client 注入 tuiTheme 并注册 palette;同一 Plugin 的其他能力照常注入各自
服务。不要写网页的 theme.overrideTokens、CSS 变量或主题条件。
最小插件
export const inject = ["tuiTheme"];
export function apply(ctx) {
ctx.effect(() => ctx.tuiTheme.register({
id: "ember",
label: "Ember",
dark: { /* 18 tokens, see fixtures/demo-skin.v0.json */ },
light: { /* 18 tokens */ },
}));
}
即时预览只需把注册改为:
ctx.tuiTheme.register(palette, { activate: true })
不需要监听主题或手写回切;/theme 管理整个 Plugin 的替换。
当前可调用:tuiSlots
宿主声明四个 list slot:根级独立右栏 chrome.right、占 composer 顶沿且可在其边框内
展开的 conversation.input.dock、composer 内部位于输入区与模式行之间的
conversation.navigation.dock,以及 composer 外侧的紧凑统计席
conversation.composer.dock。插件先
inject 等待槽位,再用稳定的
contribution id register 任意 TuiNode[] 组合:group、markdown、
reasoning、user、generic、terminal、diff、image、notice、
unknown。完整字段见 tui-node.v0.schema.json。
export const inject = ['tuiSlots']
export function apply(ctx) {
ctx.tuiSlots.inject('chrome.right', () => {
const panel = ctx.tuiSlots.register(
{ name: 'chrome.right', id: 'build-monitor', order: 20 },
[
{ id: 'title', kind: 'markdown', text: '# Build monitor' },
{ id: 'tests', kind: 'generic', title: 'Tests', body: '97 passed', status: 'ok' },
],
)
return panel.dispose
})
}
panel.update(nextNodes) 原地替换该 contribution 的完整树并立即重绘;停止或
卸载插件会撤销 contribution,最后一个 contribution 消失时右栏立刻收起。
终端过窄时宿主暂时隐藏右栏但保留状态,变宽后自动恢复。节点 id 在一个
contribution 内稳定即可,聚合器会用 contribution id 做命名空间。
三个 conversation dock 使用相同 API,只需把 name 改为对应 slot。generic 节点在
conversation.input.dock 中聚合到 cap 行,与 Tip 竞争并优先于 Tip;同一 contribution
临时发布的非 generic 结构化节点会在圆角 composer 内、输入区上方展开。内置
queue-view 用这个机制持续呈现全部 Queue 条目,并在选择态增加焦点。Plan、Goal 等正常态仍应保持一行摘要,完整审阅内容
用本地 command 打开 overlay。Agent、branch、session 等横向导航放
conversation.navigation.dock;环境统计等紧凑读数放 conversation.composer.dock
(内置 stats-view 是默认消费者),不要向该统计席塞入多行内容。
generic 节点可选的 tone 字段直接引用既有通用 Theme token(例如 brand、ok、
warn、err、caption),不能声明新 token,也不要为单一业务发明颜色语义。瞬时
键盘焦点可用通用布尔字段 selected: true,painter 会以反显样式绘制;不要把它当作
持久选中值或另一个状态颜色。
Harness 标识席位
conversation.harness 是 session scope 的 single seat,位于模型名前。
内置 harness-badge 根据当前 tab 的连接身份匹配 Registry,只从 Registry 的
icon URL 下载图标;PNG 缓存在 settings 同级 cache/harness-icons,SVG 由
Client 树转为 PNG。无内置品牌素材。首次加载、缺失、失败和非图片终端显示名称。
该 seat 接收单个 image 节点:id 为所属 session id,name 为名称回退,
mime 为 image/png,可选 dataBase64 为图标。painter 校验 session 归属并负责
图片位置、回收;迟到的旧会话快照不能在新 tab 显示。插件不接触 TTY。
当前可调用:tuiOverlay
openSlider(options, handlers) 提供数值滑条;openSelect(options, handlers) 提供
普通单选表单;openView({ id, title, nodes }) 提供只读 TuiNode[] 弹窗。三者共享
一个 modal 席位,返回的 close() 和插件 Fiber 同生命周期。View 支持上下翻阅,
Enter 提交、Esc 取消;它是通用节点视图,不认识 Plan。
Select 的 options[].group?: string 是单行装饰性分组标题,带分割线,不是可选项,
不参与导航或提交。同 id 的 openSelect 可原地刷新候选和 handlers,不先关闭面板;
Rust 保留搜索文字和仍然可见的选中值。旧 controller 不会关闭刷新后的面板。
options[].disabled?: boolean 保留行的显示但禁止提交;本地命令 input 候选也
接受 disabled,Enter / Tab 不会提交或补全禁用项。
Select 可通过 options[].deletable?: boolean 和 handlers.onDelete(value)
声明行删除动作。Delete 关闭当前表单并派发独立的 delete 事件,不等同于提交;
未声明、禁用项或没有 handler 时不执行。非搜索表单也接受 Backspace(Mac Delete);
搜索表单的 Backspace 仍只编辑搜索词。业务插件负责展示确认和执行删除。
本地命令可在 tuiCommands.register 的 options 中声明
input: { hint, options: [{ value, label, description }] }。用户输入 /name 后,Rust
painter 会复用现有上拉 slash 菜单展示候选;注册返回的 disposer 还提供
update({ input }),供 UI Plugin、模型等动态目录刷新候选。标准 ACP
AvailableCommand.input 当前只有 hint,没有枚举候选;Agent 命令因此展示标准
hint,只有 Client 插件扩展或可从 ACP config/catalog 推导的命令才列出候选。
Host 数据与轮询
动态 Package 的 Host half 用 harness.handle(method, handler) 注册仅属于该
Package 的 JSON 方法,Client half 用 host.call(method, args) 调用。参数与返回值
必须是无损 JSON;省略 args 时 Host 收到 null。
需要定时刷新时同时注入 timer,使用随 Plugin run 自动回收的
ctx.interval,不要调用 Node 的裸 setInterval:
return {
inject: ['tuiSlots', 'timer'],
apply(ctx) {
const panel = ctx.tuiSlots.register(
{ name: 'chrome.right', id: 'monitor' },
[{ id: 'loading', kind: 'notice', level: 'info', text: 'Loading…' }],
)
let refreshing = false
const refresh = async () => {
if (refreshing) return
refreshing = true
try {
const state = await host.call('read-state')
panel.update([{ id: 'state', kind: 'generic', title: 'State', body: state.text }])
} catch (error) {
panel.update([{ id: 'error', kind: 'notice', level: 'error', text: String(error) }])
} finally {
refreshing = false
}
}
void refresh()
ctx.interval(() => void refresh(), 1000)
},
}
host.call 走协商后的 ACP _dsh/cordis/plugin/invoke Extension Request;timer 和 Slot 都只存在于 Client 树,
不会进入 session/update 或会话历史。
动态 Creator 插件先查询 Client inspect 的 Slots.list,再让 code.client
注入 tuiSlots。这是终端节点协议,不是 Web React 的 ctx.slots。
可直接参考未默认挂载的 gallery:@openma/deepseek-harness-tui/right-demo。
- insert:
- id: tui-right-demo
name: '@openma/deepseek-harness-tui/right-demo'
没有这些 API
插件模块拿不到:TTY、stdin、raw mode、kitty 转义、ratatui、JSON-RPC 方法表、fd 3/4、_dsh/cordis/tui/* transport、整屏行列、帧循环、按像素或「底 N 行」的坐标。
节点上不得写 RGB;着色只能引用 token 名。RGB 只出现在配色包的 dark/light 表里。
持久化与加载
从 Cordis 临时 run 升级为常驻 Plugin
cordis_define / cordis_run 创建的是当前进程内的临时 Plugin。先用
cordis_inspect_self(pluginId, packageId) 读取当前成功 Package 的完整源码和诊断,
再根据 Package 是否包含 Host half 选择持久化路径。
Client-only Package 在 cordis_run 成功后,可以调用 tui_plugin_save 写入
$MARTTY_HOME/plugins/<artifact-id>/plugin.json。Client 启动时会重新发现并装载它。
只要存在 code.host,无论是 Host-only 还是双向 Package,都必须整理成普通 Cordis
npm Package 或本地 Package,再使用下面的命令安装到 profile:
dsh plugin --profile martty add <package-or-path>
安装后,Harness 在启动时加载 Host registrar,并把可序列化的 Client entry 清单交给
Martty Client。当前没有通用的 promote 工具把动态定义直接写进 profile;动态预览
与静态安装之间保留显式的保存或打包步骤。
TUI 合并三个来源,按 builtin、已安装 package、Creator artifact 的顺序装入同一
/ui 与 /theme catalog:
- builtin 随 TUI 包发布;
- 第三方 package 由
dsh plugin --profile martty add <package-or-path>安装,其 Host registrar 向tuiClientPlugins登记绝对 Client entry;Host runner 只把这份 JSON 清单交给独立 Client 进程; - Client-only Creator artifact 位于
$MARTTY_HOME/plugins/<artifact-id>/plugin.json,由tui_plugin_list/read/save/remove管理。已安装 package 与本地 artifact 同 id 时, package 优先,本地项作为 shadowed diagnostic 保留而不执行。
tui_plugin_save 只接受没有 code.host 的 Package。只要存在 Host half,无论是否同时
存在 Client half,完整 Plugin 都归当前 Harness,并使用 Harness 自己的持久安装路径;
Martty 不把 Host 代码写入 .martty。
MARTTY_HOME 的解析顺序是显式环境变量、$DSH_HOME/.martty、~/.martty。
旧 $DSH_HOME/.tui-plugins 与 ~/.dsh-tui/sessions/dsh-tui-settings.json 在首次启动时
只复制到新位置;不删除旧数据,也不覆盖已经存在的 Martty 数据。
第三方包的 Host registrar 是普通 Cordis 插件:
export const inject = ['tuiClientPlugins']
export function apply(ctx) {
return ctx.tuiClientPlugins.register({
id: 'paper-lantern',
kind: 'theme',
entry: new URL('./client.js', import.meta.url).href,
})
}
client.js 导出普通的 inject / apply Client Plugin。包自己的 bundle patch 把
registrar 插入 Host profile;registrar 只登记可序列化 entry,不把 Host fiber、服务或
插件树同步给 Client。
Cordis 没有「挂在某个插件实例下面」的父子指针。tui-theme、配色包、
tui-cordis-client-runner 和 tui-runner 在 Client 树里是平级生命周期。
真正等 TUI 能力的协议是服务名:
tui-theme/tui-slots分别提供ctx.tuiTheme/ctx.tuiSlots。tui-cordis-client-runner注入两者,向 Host 发布 inspect 目录并执行动态code.client。- 第三方 Client module 声明对应 inject;服务不在就 PENDING,不靠 Host yaml 行序。
- package 与 Creator artifact 都经过相同的受限 Client facade;Theme owner 的 start/stop 与 UI Plugin 的常驻 catalog 生命周期由 Client manager 收集。
不要在 runner 里 ctx.plugin(ember) 来叠第三方。第三方包由组合层作为 sibling
挂载。Web 的 ctx.slots 与终端的 ctx.tuiSlots 是不同服务。
本地包走 dsh plugin --profile martty add ./path,加载 package exports。新 registrar
要重开 TUI 或以后的 /refresh。已挂配色包改源码后的热换按阶段落地。
后续
conversation.chat 等会话槽仍未开放;ACP session/update 继续是 transcript 真源。