# TUI 架构 · 一个 Elm 式的终端界面

> 进 Part 5，钻进那个约 55 万行（含测试）的终端 UI。这一章看 grok-pager 的整体架构：Elm 式的 Action→dispatch→Effect 三段式、一个精心排序的 biased 事件循环、专用读写线程，以及为什么「派发层不碰终端/网络/文件」是它最值得学的决定。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/tui-architecture
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# TUI 架构 · 一个 Elm 式的终端界面

前面四部分讲的都是「引擎」。Part 5 钻进最外层——你真正看到、敲键的那个终端界面(`xai-grok-pager` + `-render`，两者 `src/` 下的 Rust 代码合计约 55 万行，不含测试约 26.5 万行，基于 ratatui)。别以为 TUI 是薄壳，它的架构相当讲究。

## Elm 式三段式

`app/actions.rs` 的模块注释写得很直白：**event → dispatch → effect**。三个核心类型：

- **`Action`**(约 300 个变体)：**同步、无副作用的用户意图**，由输入产生。`SendPrompt`、`CancelTurn`、`SwitchModel`……
- **`Effect`**：异步副作用，丢进 `JoinSet` 跑。
- **`TaskResult`**：异步任务的结果，包回成 `Action::TaskComplete` 再走一遍 dispatch。

```mermaid
flowchart LR
  IN[按键/鼠标输入] --> A[Action 同步意图]
  A --> D["dispatch:改状态 + 产出 Effect<br/>(不碰终端/网络/文件)"]
  ACP[ACP 事件] --> H["acp_handler:直接改状态<br/>需要时排 Effect"]
  D --> E["effects:跑异步副作用<br/>(spawn tokio 任务)"]
  H --> E
  E --> TR[TaskResult] --> A2[Action::TaskComplete]
  A2 --> D
```

## 最值得学的决定：派发层 sans-IO

`app/dispatch/` 的模块注释一句话点破：它做的是「**Action → 状态改动 + Effect**」，**从不碰终端、网络、文件系统**——因此「**不用 tokio、不用终端就能完整测试**」。所有真正的副作用被隔离在 `effects/`。这个「**纯派发 + 隔离副作用**」的分层，是让一个几百个 Action 的复杂 TUI 还能测的关键。ACP 事件(来自 [Part 1 的 SessionActor](https://daiw.org/manual/grok-build/agent-loop))则不走 Action，由 `acp_handler/` 直接转成 scrollback 变更，需要副作用时同样排成 Effect。

## 一个排序讲究的事件循环

`event_loop.rs` 的核心是一个 **`biased` 的 `tokio::select!`**，分支顺序本身就是设计：**取消/退出 > 写线程回执与阻塞报告 > ACP(仅当输入队列空时才收，免得 token 洪流饿死按键)> 后台任务 > 进度 > 输入 > 各种定时器与配置/外观监听 > 语音 STT(最后，因为热麦会持续推送临时转写，突发时能塞满 128 槽的通道，绝不能饿死别的)**。定时器分支用 `match at { Some=>sleep_until, None=>pending() }` 的惯用法，只在需要时武装。

两个专用线程也值得记：

- **读线程**：专门 `poll` + `read` 键盘鼠标，转发到 tokio 通道——`select!` 里别的分支赢了，通道的 recv future 被丢弃重建也不丢唤醒；而直接在 `select!` 里 poll crossterm 的 `EventStream` 不是 cancel-safe 的(crossterm 的已知 issue)。
- **写线程**：缓一帧、把字节送出去，**把 PTY 背压和 tokio 循环隔开**。

## 状态与视图

`AppView` 持有**全部**状态(约 220 个字段：活动视图、各 agent、滚动、通知服务、鉴权/信任……)。`ActiveView` 分 `Welcome / Agent(id) / AgentDashboard`；每个 agent 有自己的 `AgentView`(输入 + 绘制，各种 overlay 门控输入)。绘制节流靠 `min_draw_interval`，空帧直接跳过光标转义。

一个端到端的小例子是**状态行**(`[ui.status_line]`，默认关)：底部一行显示模型、上下文占用、花费、目录，或者你自己脚本的输出。它的数据契约在独立的小 crate `xai-grok-status-line` 里：agent 把 `StatusLineContext` 经 ACP 推给客户端，`acp_handler` 把它存进 `AgentView`；`command` 模式跑脚本则完整走一遍三段式——`Effect::RunStatusLineCommand` 进 `JoinSet`，结果以 `TaskResult` 回来；定时刷新也只是 `select!` 里又一个按需武装的定时器分支。

## 和《从 LLM 到 Coding Agent》对照

教学最小实现的「界面」通常就是 `print` + `readline`。grok 的 TUI 是一个**完整的 Elm 式应用**：sans-IO 可测派发、饿死感知的事件循环、cancel-safe 的读线程、背压隔离的写线程。这是「命令行玩具」和「你愿意每天用的终端产品」之间的全部距离。下一章看它怎么把文字画到屏幕上。

---

上一篇：[自定义模型](https://daiw.org/manual/grok-build/custom-models) · 下一篇：[渲染管线与 scrollback](https://daiw.org/manual/grok-build/render-pipeline)
