跳至正文
浏览章节

UI 即数据

一套视图词汇、按持久性分出的三条通路,以及一条让任何界面都不丢失信息的降级规则。

它解决的问题

一个插件应当能够把丰富、实时、可交互的东西放到屏幕上——一段 diff、一块看板、一条进度条、 一张表单——而终端界面根本不必知道那个插件存在。有三条规则框住了答案:只有终端界面 crate 可以依赖 ratatui,没有界面能定义内核类型的私有镜像,而内核不按名字认识任何插件。

答案是:插件把要显示的东西描述成数据,而每个界面自己决定怎么画(ADR-0013)。

这套词汇

bingo_sdk::View 是一棵小小的声明式的树。

叶子

节点字段
Texttext
Markdowntext
Codelang?、text
Diffunified
Listitems
Tableheaders、rows
KeyValuerows
Progressvalue、total?、label?
Badgetext、tone
Treenodes

容器 —— 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围栏、高亮,超过八行时带行号那段文本
diffunified 格式,按列上色,词级强调unified 那段文本
table / key-value细线、数字右对齐、缺失的格子用 –各行用 · 连起来
progress渐变填充,无界时有一道光泽label 80 %
badge以该色调画的 [ text ][text]
tree带符号和徽标的 ├─ └─缩进的行
imagekitty、iTerm2 或 sixel,都不行就用半块字符[image: name]

三条通路,靠持久性区分

通路调用生命期画成
blockToolOutput.display = Some(view)随条目一起,在对话记录里在工具行下面,像任何输出一样折叠
panelhost.extend(session, plugin, kind, view)进日志;--continue 之后还在一张边栏卡片,或者面板浮层里的一行
livehost.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 写插件。