TUI 的哲学:透明度不是美化——是代理可信赖的基础设施
这是《写一个 Coding Agent Harness and TUI 》系列的第 5 篇。 Agent 的逻辑跑通了——但它还活在 stdin/stdout 里。 这篇给它穿戴一个终端界面。但重点不是"怎么画"——是"为什么画什么"。
你用过 GitHub Copilot。你跟它之间的交互是什么?它给你一个建议,你决定用不用。信息流是单向的:Copilot → 你。
Coding agent 不一样。你给它一个指令,它自主决定:读哪些文件、改哪些代码、执行什么命令、要不要重新编译。信息流是多轮的、多角色的。agent 可能连续调 8 次工具才完成任务。你可能在中间任何时刻想打断它、想确认它在正确的方向上、想理解它为什么选了这条路。
你跟 agent 之间需要一种 Copilot 不需要的东西:对话式的、可追溯的、分角色的信息界面。 这不是"更好看的终端"——这是"人类能理解自主代理行为"的基础设施。
TUI 要解决的根本问题不是"怎么渲染"——是**“agent 的决策过程如何变成人类能理解的信息”。** 每一帧渲染都在回答"agent 现在在干什么、为什么、下一步大概率是什么"。
〇、元问题:自主系统需要"人类可理解的中间状态"
自驾车撞了人。调查员调出行车记录
每一秒的方向盘角度。刹车踏板的压力曲线。摄像头画面。雷达信号。决策模块的输出(“检测到行人,启动紧急制动,制动距离不足”)。
调查员不需要相信自驾车的最终判断。他需要验证中间每一步。
你的 agent 也需要"行车记录仪"。不是给你看的——是给用户看的。用户在 agent 运行 30 秒之后看到一段代码输出,他需要回答:“这个结果可信吗?“如果他能回溯 agent 的中间状态——看到 read_file 读了哪个文件、search_replace 改了什么、cargo check 的结果——他就能自己判断答案。
如果他看不到这些——他只能在"盲目信任"和"放弃使用"之间二选一。 大多数人选第二个。
TUI 作为行车记录仪的设计约束
- 时序准确:消息必须按 agent 实际执行顺序渲染。先读文件,后改代码,再编译——不能因为渲染管线的缓冲而乱序
- 角色可区分:用户一眼能区分"这是 agent 的思考"“这是工具执行"“这是最终回复”——不能都是一种颜色
- 信息密度可调:用户有时候需要看细节(“这个工具调用传了什么参数”),有时候只需要概览(“agent 调了几次工具”)
- 不可阻塞:agent 在流式输出时,用户的键盘不能卡住。两个数据流必须互不干扰
这四个约束直接对应了五个技术决策。
一、ratatui 立即模式 vs cursive 保留模式——不是框架之争,是渲染哲学之争
两种哲学
保留模式:你告诉框架"界面上有什么”——一个 menu widget,一个 scrollback widget,一个 input widget。框架负责维护这些 widget 的状态,决定什么时候重绘哪些区域。你描述 WHAT(界面的静态结构)。
立即模式:每帧你告诉框架"这一帧画什么”——画 3 行 header、画 scrollback 的第 100-130 行、画一行 input。框架把你给的指令直接渲染到终端缓冲区。你描述 HOW(每一帧的具体内容)。
为什么保留模式对 scrollback 是错误的
保留模式的核心抽象是 widget 树。框架有以下职责:layout(计算每个 widget 的位置和大小)、dirty tracking(标记哪些 widget 需要重绘)、event dispatch(把键盘事件路由到正确的 widget)。
这三个职责,对 scrollback 全是浪费。
layout:scrollback 的布局是"占满剩余空间”。没有左侧边栏、右侧面板、浮动窗口。widget 树的 layout 算法在 scrollback 上做的一直是同一件事——“给 scrollback 分配所有可用的竖直空间”。
dirty tracking:scrollback 的 dirty 标记永远是 true。因为 agent 每 50ms 吐一个 token,scrollback 每 50ms 变一个字符。widget 树框架花了 CPU 周期去计算"哪些区域需要重绘"——每 50ms 一次——每次计算结果都是"全部重绘"。
event dispatch:键盘事件永远路由到 input widget。没有菜单,没有按钮,没有弹出窗口。widget 树的事件分发机制在 scrollback 场景下退化为 1→1 路由。
保留模式不是"不好"——是这个场景下 90% 的框架能力被浪费了。
什么时候保留模式更好
界面有稳定的结构划分:菜单栏 + 侧边栏 + 主区域 + 状态栏——四个 widget,长期不变。侧边栏偶尔展开/折叠,框架帮你管理动画和布局调整。
coding agent 的 TUI 没有这种结构。它的界面是"一个输入框 + 一个不断增长的文本流"。用保留模式渲染这个结构 = 用一辆卡车运一个信封。
grok-build 为什么选 ratatui
grok-build 的 pager(xai-grok-pager)完全用 ratatui 构建。不是因为 ratatui"更好"——是因为立即模式在这个场景下是语义匹配的。scrollback 的变化模式(线性追加、永不删除、高频微变)和立即模式的渲染模式(每帧全量描述、无中间状态)是天然吻合的。
grok-build:
pager/src/app/app_view.rs
二、select! 直接轮询 vs channel 中转——不只是延迟,是"流畅感"的本质
channel 方案为什么"几乎是对的"
// 三个 channel,三个 task
let (key_tx, key_rx) = mpsc::channel(1);
let (llm_tx, llm_rx) = mpsc::channel(4);
let (tick_tx, tick_rx) = mpsc::channel(1);
tokio::spawn(async { loop { key_tx.send(read_key().await).unwrap(); } });
tokio::spawn(async { loop { llm_tx.send(llm_stream.next().await).unwrap(); } });
loop {
tokio::select! {
key = key_rx.recv() => handle_key(key),
chunk = llm_rx.recv() => handle_chunk(chunk),
_ = interval.tick() => redraw(),
}
}
这个方案在正确性上没有 bug。所有消息都到达了,没有丢包,没有乱序。
但它引入了一个不可见的代价:buffer slot 延迟
mpsc channel 有一个 buffer。即使你设 capacity(0)(rendezvous channel),sender 发送时要等 receiver 准备好——这仍然是一次 context switch。
LLM 每秒吐 20-50 个 token。每个 token 经过 channel → tokio 调度器 → receiver → 渲染。 每个 token 在 channel 层面多花了 ~100μs-1ms。50 个 token × 0.5ms = 25ms 额外延迟。每秒。
25ms 听起来不多。但它是均匀分布在每个 token 上的——每个 token 多 0.5ms。 这 0.5ms 在视觉上体现为"文字流出现微卡顿"——不是整个画面卡,是 token 之间不连续。前一个 token 出现了,后一个 token 隔了一小段空白再出现,而不是紧接着流淌出来。
select! 直接轮询
// LLM stream 在主循环里直接 poll
let mut llm_stream = Box::pin(client.chat_stream(...));
loop {
tokio::select! {
Ok(event) = crossterm::event::read() => handle_key(event),
Some(chunk) = llm_stream.next() => {
let text = &chunk.choices[0].delta.content;
scrollback.push(text); redraw();
}
_ = interval.tick() => redraw(),
}
}
LLM stream 的 .next() 在主循环里被直接 poll。chunk 到达 → select! 分支触发 → token 追加到 scrollback → 下一次 redraw() 把它渲染出来。没有中间 channel,没有 context switch,没有 buffer slot 延迟。
为什么这很重要——不只是"性能"
用户对 agent 的信任建立有一个潜意识的维度:agent 的"存在感"。 流式 token 的输出是 agent 的"呼吸"——连续、流畅、没有中断。呼吸突然停顿 100ms,用户的大脑会 flag 这个停顿——“是不是卡住了?是不是在重试?是不是出错了?“即使 100ms 后正常恢复,信任已经受损。
大量微小的停顿 = 累积的不安全感。流畅的 continuous stream = 累积的信任。 你花在 channel overhead 上的 25ms/秒,是在蚕食用户的信任余额。
grok-build 的
pager/src/app/event_loop.rs— 4140 行事件循环。监听 10+ 个事件源(ACP notification、replay buffer、config watcher、theme watcher、animation tick),全是直接的 Future,没有 channel 中转。
三、Block 语义单元 vs 按行存储——视觉语法 > 颜色区分
按行存储的 scrollback
scrollback: Vec<String> // ["用户: 帮我重构", "助手: 好的", "🔧 bash(cargo check)", ...]
每一行是一段文本。你靠内容区分"这是用户消息还是 agent 回复”。
问题不是"区分不了”——是"区分需要认知劳动力"。 你需要读每一行的前几个字才能判断它的角色。扫描滚动条时,你无法靠视觉 patterns 快速定位"agent 的回复从哪一行开始"。
颜色是弱区分信号。色弱用户看不到颜色区别。终端主题切换后颜色可能丢失。截图分享时颜色不一定保留。
Block 语义存储
enum Block {
User(String), // 青色边框,左对齐
Assistant(String), // 绿色边框,左对齐
ToolCall { // 黄色边框,可折叠
name: String,
args: String,
result: String,
duration_ms: u64,
},
Thinking(String), // 灰色,斜体
Error(String), // 红色边框
}
每种语义有其独立的视觉语法——不只是颜色。 边框风格、缩进、前缀图标、可折叠性——这些是比颜色更 robust 的区分信号。
用户扫一眼 scrollback:看到 3 个黄色 Block = agent 调了 3 次工具。看到 1 个红色 Block = 有错误。看到 1 个绿色 Block = agent 回复。不需要读任何内容,已经理解了 agent 的工作流。
视觉语法 = 降低认知负荷 = 更快建立信任
这是信息设计,不是美化。扫一眼就能判断"agent 在正常流程中还是出错了"的能力,直接转化为"我敢不敢继续用这个 agent"的信心。
grok-build 把这个思想执行得很彻底。它的 scrollback 有 10+ 种 Block 类型:
AgentMessage:agent 的文字回复Thinking:CoT(chain of thought)推理过程ToolCall:工具调用——展开显示参数和结果,折叠显示摘要UserPrompt:用户的输入SystemMessage:系统级通知(权限更新、配置变更)Error:错误Diff:代码 diff(绿色/红色行)
每种有独立的渲染函数、折叠逻辑、时间戳显示。
grok-build:
pager/src/scrollback/blocks/
四、TerminalGuard RAII——“代码正确"和"代码鲁棒"是两个维度
裸 enable/disable
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
// ... TUI 逻辑 ...
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
这段代码在正确路径上是正确的。程序按顺序执行到最后两行,终端恢复。
但如果 LLM 返回了格式异常的 JSON——serde_json::from_str panic。如果在 1000 行文件上执行 search_replace 时 replace panic。panic 发生后 Rust 的 unwinding 跳过 panic 点之后的所有代码——disable_raw_mode 永远不会执行。
用户的终端现在在 raw mode。打任何东西都是一堆控制字符。Enter 不换行。Ctrl+C 不管用。唯一的解决方法是关掉终端窗口重开。
RAII guard
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen)?;
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
// 用法
let _guard = TerminalGuard::enter()?;
// ... TUI 逻辑 ... // 即使这里 panic,_guard 的 Drop 也会执行
Rust 的 unwinding 机制保证所有栈上对象的 Drop 被调用。不管在哪里 panic——TerminalGuard 的 Drop 永远执行。
这不是代码风格——这是"程序对外部世界的承诺”
TUI 提出了两个承诺:(1) “我要接管你的终端”;(2) “我离开时会还给你”。
手写的 enable/disable 满足了正确路径上的承诺 2。RAII guard 满足了所有路径上的承诺 2——包括你无法预测的 panic 路径。
“代码正确"是说:给定预期输入,产生预期输出。“代码鲁棒"是说:给定任何输入(包括会触发 bug 的输入),不破坏外部世界。
在 agent 的场景下,“不破坏外部世界"尤其重要。 因为 LLM 是一个你会收到无法预测的输出的系统。你不知道下一次 API 调用返回的 JSON 是否合法。你不知道 LLM 会不会返回一个让你的解析器 panic 的 edge case。你的 TUI 必须在这种不确定性下保持鲁棒。
五、syntect vs tree-sitter——容错性比正确性更重要
LLM 输出的特征
agent 输出的代码经常:
- 不完整(写到一半的 impl 块)
- 语法错误(LLM 常见问题)
- 混用不同语言(在 Rust 代码里内嵌一段 TOML 配置)
tree-sitter 的问题
tree-sitter 构建完整 CST(具体语法树)。解析器在 AST 层面工作——它需要完整的语法单元才能构建合法的树节点。
不完整的 impl 块 → 解析失败 → 整个 block 无高亮。语法错误 → 解析失败 → 错误标记替代高亮。混用语言 → 按第一段语言解析 → 第二段被标记为错误。
tree-sitter 的"正确性保证"在这个场景下变成了"可用性障碍”。 你关心的不是 CST 是否正确——你关心的是用户能不能大致读懂这段代码。
syntect 的工作方式
syntect 基于 regex + scope stack。它不是"解析"代码——是"对文本应用模式匹配规则”。
不完整的 impl 块 → fn、let、-> 等关键字可能匹配到 → 高亮。未闭合的括号 → 不影响周围 token 的匹配。混用语言 → 按 scope push/pop 规则,部分 token 匹配到。
syntect 给出的是"70% 的高亮覆盖”——大部分 token 有颜色,只有未完成或异常的部分没有。tree-sitter 给出的是"0% 或 100%"——要么全部高亮,要么全部无高亮。
对于 LLM 输出的代码,70% 的高亮 > 0% 的高亮。容错性 > 正确性。
这和第 4 篇的 search_replace 原则是一致的
search_replace 的 fail-safe 设计:工具不替 LLM 猜——给它错误让它修正。syntect 的容错设计:解析器不因为不完整就全部拒绝——给它能做的部分,剩下的留给 user 自己判断。
“拒绝"和"容错"不是矛盾——它们作用于不同的信息通道。 文件编辑是写操作——拒绝错误信息让 LLM 学习。语法高亮是读操作——容忍不完整让人类用户能消费信息。
写操作需要 fail-safe。读操作需要 best-effort。判断标准是:这个操作的失败后果谁来承担?写操作的后果是永久性的(文件被改了)。读操作的后果是暂时的(这一帧渲染不好,下一帧可以好)。
六、总结:五个决策,一个根本问题
| 决策 | 回答的问题 |
|---|---|
| 立即模式渲染 | “界面的变化模式是什么?"→ 线性追加,不是树形变化 |
| select! 直轮询 | “流畅感的代价是什么?"→ channel 的 buffer slot 延迟蚕食信任 |
| Block 语义单元 | “怎么让用户不读内容就能理解 agent 在干什么?"→ 视觉语法 |
| RAII guard | “panic 时对终端用户的承诺能不能兑现?"→ 必须用 Drop 保证 |
| syntect 容错 | “这个操作的失败后果谁来承担?"→ 读操作可以容错,写操作必须 fail-safe |
TUI 给了 agent 一张可以被审计的"脸”。 下一篇——为什么这张脸和 agent 的大脑必须是两个进程。
完整代码在 tutorial-code/part-05-tui/。
🏢 企业级 Agent 落地? 本教程技术栈已在真实生产环境验证。扫码联系获取技术选型 + 安全方案咨询。

