第 9 章:事件如何变成 TUI
模型 token、工具进度、权限请求和回合终止不会直接调用某个 widget。Grok Build 先把后端行为转成 ACP 更新,再由 TUI 的状态机归一、分派、执行 effect 并渲染。本章沿这条反向数据流阅读。
9.1 四种容易混淆的“事件”
| 名称 | 产生者 | 消费者 | 例子 |
|---|---|---|---|
SamplingEvent | Sampler | SessionActor | token、tool delta、retry |
| Session/ACP Update | Session/Persistence | Client | agent text、tool status、plan、mode |
Action | TUI input/ACP handler/task result | App dispatch | send prompt、open modal |
Effect/TaskResult | dispatch/async executor | dispatch | network request、文件选择完成 |
它们属于不同层,不能用一个超大 enum 横跨系统。每层只暴露下层真正需要的语义。
9.2 后端流到 ACP 更新
一次模型文本增量大致经过:
SessionActor 在中间承担:
- 绑定 session/prompt/request 身份;
- 区分 text 与 reasoning;
- 聚合 tool call;
- 生成生命周期事件;
- 送持久化;
- 控制哪些更新对客户端可见。
因此 ACP update 是业务事件,不是供应商流的镜像。
9.3 为什么要持久化更新再谈 UI
如果 UI 只靠实时 callback:
- Client 断线就失去期间输出;
- 新 viewer 无法附着;
- 崩溃恢复只能猜当前状态;
- Leader 无法在 replay 与 live 之间建立顺序。
Grok Build 让大多数有意义的 session update 同时成为恢复材料。UI 是这些更新的一个投影。
9.4 TUI 的 Action—Effect—TaskResult
xai-grok-pager/src/app/actions.rs 的模块说明给出清晰约束:
Action:同步、无副作用的用户意图或状态输入;Effect:dispatch 决定要执行的异步副作用;TaskResult:Effect 完成后重新送回 dispatch 的结果。
这是 Elm/Redux 风格单向数据流在终端应用中的变体。
9.5 为什么 dispatch 要保持同步
如果 reducer/dispatch 内直接 await:
- 键盘输入可能被一个慢 ACP 请求阻塞;
- 同一操作的中间 UI 状态不可预测;
- task completion 会在任意调用栈修改 view;
- 测试必须启动完整 runtime 才能验证简单状态转换。
同步 dispatch 先更新“正在加载/等待”状态,再返回 Effect。Effect 在 JoinSet 中执行,结果作为新 Action 回来。这使每次状态变化都有明确入口。
9.6 Event Loop 要“薄”
app/event_loop.rs 的职责是 tokio::select! 多种输入并转成 Action,包括:
- terminal events;
- ACP message;
- effect task completion;
- render/tick;
- config/theme hot reload;
- update/roster;
- voice 或其他可选输入。
它不应该包含大量具体业务逻辑。业务分支放在 dispatch 与 AgentView,否则 select loop 会变成无法测试的全局上帝函数。
9.7 AppView 与多 Agent 视图
AppView 持有:
- 当前 screen/dashboard;
- 一个或多个 agent tabs/views;
- modal、input、theme、layout;
- scrollback 与 rendering state;
- pending effect/interaction 状态。
每个 AgentView 追踪一个 session 在客户端侧的投影。Leader 模式下,当前客户端可能是 driver,也可能只是 viewer;两者收到的完成语义不同。
9.8 ACP Handler 的归一化
TUI 的 ACP handler 把通用 session update 转成更具体的 scrollback block/state:
- agent text chunk 合并;
- reasoning block;
- tool start/progress/end;
- permission/modal;
- plan、todo、goal;
- session lifecycle marker;
- errors、retry、compaction;
- background task completion。
这一步需要幂等和关联性。网络重连或 replay 可能再次送来逻辑上相同的状态;handler 不能简单 append 所有东西。
9.9 Scrollback 不是字符串数组
Scrollback 由 typed block 组成:
- user/assistant message;
- reasoning;
- tool block 及其进度;
- lifecycle/session event;
- code/diff/diagnostics;
- completion chip;
- parked/working marker。
Typed block 支持:
- 根据终端宽度重排;
- tool progress 原地更新;
- 搜索;
- 折叠 reasoning/tool;
- 主题渲染;
- replay 时恢复结构。
如果只存 ANSI 文本,终端 resize 和状态更新都会非常困难。
9.10 完成事件为什么复杂
Client 看到“模型停止”后,session 可能仍有:
- stop hook;
- background command/subagent;
- goal 自动继续;
- driver reconcile;
- parked marker;
- 收尾持久化。
TUI 的 turn completion 测试覆盖很多边界:
- viewer 可直接 idle,driver 可能先进入 reconcile;
- 重复 terminal 必须 no-op;
- parked tail 与真实 completion 合并,避免重复 marker;
- failure 不应被折叠成成功;
- background chip 不能被错误吞并;
- prompt id 不匹配时不能结束别的 turn。
这说明 UI completion 是一个协议状态机,而非“收到 stop 就打印换行”。
9.11 Driver 与 Viewer
在多客户端情况下:
| 身份 | 能力 |
|---|---|
| Driver | 发 prompt、回答交互、驱动当前 turn |
| Viewer | 重放并观察 session |
viewer 收到终止更新后可以把本地视图置 idle;driver 还可能等待 request response 或 reconcile,以确认发起的 prompt 真正闭环。
把这两个路径分开,可以避免 viewer 因没有对应 JSON-RPC response 永久等待,也避免 driver 仅凭广播 update 提前结束。
9.12 插话、排队与 Cancel-now
Action 系统区分:
- 正常发送 prompt;
- turn 中 interjection;
- queued message;
- send-now/cancel-current;
- cancel;
- scheduled/automatic prompt。
它们最后都可能携带文本,但控制语义完全不同。例如 send-now 需要取消当前动作再启动新 prompt,不能渲染成普通完成 marker。
9.13 Fullscreen 与 Minimal
TUI 支持 alternate-screen fullscreen 与更接近普通终端输出的 minimal 模式。二者共用 Agent/ACP 状态,但终端生命周期不同:
- mouse/paste mode;
- cursor 位置与样式;
- panic 时恢复;
- resize 与 inline 区域;
- relaunch/switch mode;
- 退出时 resume hint。
app/mod.rs 对 terminal 初始化和 teardown 有专门代码,因为一个功能正确但留下坏终端状态的 CLI 仍然是严重故障。
9.14 Backpressure 与批处理
高频 token 若每个都触发完整 render,会浪费 CPU 并产生闪烁。系统在不同层使用:
- channel;
- chunk coalescing;
- tick/render 节奏;
- typed block 原地更新;
- Leader 加载缓冲;
- persistence 连续文本合并。
正确目标不是“每个 token 立刻 repaint”,而是:
不丢语义、不乱顺序,并以人眼无感的批次更新。
9.15 错误展示的层级
| 来源 | UI 应表达 |
|---|---|
| Sampling retry | 可恢复、当前第几次重试 |
| Sampling failed | 本轮失败及可操作原因 |
| Tool result error | 某个 tool block 的终态 |
| Permission reject | 拒绝及来源,不冒充工具崩溃 |
| ACP disconnect | 连接问题与重连状态 |
| Session failure | 生命周期终止 |
| TUI effect failure | 本地 UI 操作失败 |
将所有错误都塞进聊天文本,会丢失重试、归属和恢复信息。
9.16 源码阅读卡
| 目的 | 文件 |
|---|---|
| Action 定义与约束 | app/actions.rs |
| 多源事件循环 | app/event_loop.rs |
| Effect 执行 | app/effects/mod.rs |
| ACP update 处理 | app/acp_handler/* |
| AgentView | app/agent_view/* |
| Scrollback blocks | scrollback/blocks/* |
| Session 发送更新 | acp_session_impl/updates.rs |
| 完成边界测试 | app/turn_completion/tests.rs |
9.17 本章结论
TUI 并没有参与模型决策,它维护的是 Agent 状态的客户端投影:
Sampler 产生模型事件,SessionActor 赋予会话语义,ACP 提供稳定边界,TUI 用 Action—Effect—TaskResult 把更新变成可测试的交互状态。
下一章深入持久化:这些更新如何成为事实源,并支持 load、replay、重连、fork 与 rewind。