UI 即数据
一套视图词汇、按持久性分出的三条通路,以及一条让任何界面都不丢失信息的降级规则。
它解决的问题
一个插件应当能够把丰富、实时、可交互的东西放到屏幕上——一段 diff、一块看板、一条进度条、 一张表单——而终端界面根本不必知道那个插件存在。有三条规则框住了答案:只有终端界面 crate 可以依赖 ratatui,没有界面能定义内核类型的私有镜像,而内核不按名字认识任何插件。
答案是:插件把要显示的东西描述成数据,而每个界面自己决定怎么画(ADR-0013)。
这套词汇
bingo_sdk::View 是一棵小小的声明式的树。
叶子
| 节点 | 字段 |
|---|---|
Text | text |
Markdown | text |
Code | lang?、text |
Diff | unified |
List | items |
Table | headers、rows |
KeyValue | rows |
Progress | value、total?、label? |
Badge | text、tone |
Tree | nodes |
容器 —— Stack、Columns、Panel { title, child }。
可交互 —— Actions { items },其中一项由一个标签、一个 Action { name, args }
和至多一个单键提示组成。
没有 total 的 Progress 是无界的:界面显示的是有活动,不是一个比例。Badge 的
tone 是插件手上唯一的样式钩子——neutral、good、bad 或 attention,其中
attention 意为“想要一个人来”,而界面会让它动起来。颜色归界面所有。
这套词汇里没有任何东西点名一个插件或一项功能。插件用自己拥有的数据把它组合出来。
降级
impl View {
/// The degrade: what `--print`, an IM channel and a surface that cannot
/// draw a node show instead.
pub fn fold(&self) -> String
}
每个节点都恰好有一种文本折叠,而这就是降级规则的全部。--print 打印它,IM 频道发送
它,图形界面无视它。日后新增的节点要么带着它的折叠一起交付,要么就别交付。
每一种各自丢掉什么、留下什么:
| 种类 | 画成 | 降级为 |
|---|---|---|
| markdown | 标题加粗、列表、引用、带线的表格、带下划线的链接 | 那段文本 |
| code | 围栏、高亮,超过八行时带行号 | 那段文本 |
| diff | unified 格式,按列上色,词级强调 | unified 那段文本 |
| table / key-value | 细线、数字右对齐、缺失的格子用 – | 各行用 · 连起来 |
| progress | 渐变填充,无界时有一道光泽 | label 80 % |
| badge | 以该色调画的 [ text ] | [text] |
| tree | 带符号和徽标的 ├─ └─ | 缩进的行 |
| image | kitty、iTerm2 或 sixel,都不行就用半块字符 | [image: name] |
三条通路,靠持久性区分
| 通路 | 调用 | 生命期 | 画成 |
|---|---|---|---|
| block | ToolOutput.display = Some(view) | 随条目一起,在对话记录里 | 在工具行下面,像任何输出一样折叠 |
| panel | host.extend(session, plugin, kind, view) | 进日志;--continue 之后还在 | 一张边栏卡片,或者面板浮层里的一行 |
| live | host.signal(session, plugin, kind, view) | 直到被替换或被设为 Null;恢复后就没了 | 一张就地更新的边栏卡片 |
block 是人在模型所读的那些 parts 旁边看到的东西。一次工具调用同时答出两者,而
它们谁也不是谁的摘要。
panel 是一个持久的 Event::Extension,它的载荷就是那个 kind 状态的全部——于是
客户端渲染它,插件从一份快照里把它读回来,旁边不必再留一个文件或一张映射。
live 是一个瞬时的 Event::Signal。它绝不进日志,被 reducer 按 (plugin, kind)
以最新载荷折进 SessionState.signals,由一个 Null 载荷移除,恢复之后不再存在。一条
每秒更新十次的进度条让日志一分钱也不花。
频率是发布者的自律。信号由 reducer 合并,不由内核限流;一个以 1 kHz 发布的插件是让
它自己的订阅者滞后,而 Lagged 就是为此存在的。
交互
两套机制,而且两套本来就都有了。
一行 Actions 携带一个 Action { name, args }。触发它的界面提交一个 Input::Action,
那会运行某个插件的命令——所以一个按钮就是一条有名字的命令,没有任何新东西到达内核的
分发。
任何必须停下一个回合并等一个人的东西,都是经由 ToolHost::ask 打开的一个
Interaction,和以前完全一样。
界面决定什么,而插件从不过问
位置、焦点和按键。一个面板坐在哪里、它怎么拿到焦点、哪个键触发哪个动作、一个实时信号 什么时候被折起来——终端界面为所有人决定一次,而一个图形界面会有不同的决定。
在终端里这意味着:面板是在 ctrl+t 浮层里它那一行上按 enter 钉进边栏的,而这个钉住
按会话记住;信号一到就是一张边栏卡片,不需要钉;tab 在卡片之间走,❯ 标出键盘正在
对话的那一张;在那张卡片上按一个键会触发它点名的按钮——用插件给的 key 提示,没有的话
用按钮的位置——而按钮会一直穿着 …,直到这个会话的流作答。一个插件在边栏里最多得到
八行,其余的折起来。不足 120 列时,同样的卡片画在对话记录里、运行中的那些行下面。
因此一个插件的 UI 是可移植、也能脱离终端测试的:一个 View 值用 assert_eq! 断言,
而终端界面的一张快照对每个节点各证明一次绘制,而不是对每个插件各证明一次。
那个没人用的逃生口
一个这套词汇可证明表达不了的原生控件,会是一个界面 crate——tier = "surface",
允许依赖 ratatui,由二进制装配——而绝不会是一个插件。目前没有这样的计划,而那条纪律
规则只会在第一个这样的东西被写出来的那一天才放宽。
那个完整的例子
crates/bingo-demo-ui 在一个小 crate 里实现了全部三条通路,除非 --demo-ui 打开它
否则是关着的。整个读一遍;它就是一个插件在想要一块它一无所知的屏幕时该有的形状。它的
核心部分见用 Rust 写插件。