Agent 时代的开发者界面(六):为什么做一个“不错“的 Agent GUI 这么难
系列第六篇,第三幕开篇。前五篇完成了 TUI 侧的完整深潜和 Event Sourcing 的理论框架。从这一篇开始,同一个 Agent 场景进入像素世界——拥有原生控件、滚动视图和 GPU 合成器之后,技术问题完全不同。
一、“不错“的标准不在同一基线
TUI 用户看到 box-drawing 字符画的表格——┌───┬───┐——会觉得“这是个表格“。GUI 用户看到表格没有交替行背景色、没有列排序按钮、不能横向滚动,就会觉得“这做得不好“。
这不是用户挑剔。这是两个媒介的基线不同:
| TUI 用户期望 | GUI 用户期望 | |
|---|---|---|
| 表格 | 字符对齐即可 | 原生表格、可排序、可滚动 |
| 代码块 | 等宽字体即可 | 语法高亮、复制按钮、行号 |
| 滚动 | 能滚动就行 | 惯性滚动、回弹、流畅 60fps |
| 颜色 | 有颜色区分就行 | Dark Mode 自适应、无障碍对比度 |
| 动画 | 没有也正常 | 掉帧就是 bug |
| 多模态 | 不期望 | 图片、文件、语音是基本功能 |
TUI 的“功能完备“往往已经接近 GUI 的“勉强可用“。要在 GUI 上做到“不错“,你需要处理的子系统数量通常会比 TUI 多出数倍。
第 1 篇引过一个判断:做 GUI 要么 VSCode 换皮(上限被封死),要么自研(至少半年以上)。这里把它量化:自研到底要做多少东西?以 FlowDown——我见过的开源原生 iOS Agent 客户端里技术拆分最完整的一个——为例。
二、FlowDown 的七个子系统
FlowDown 的界面层建立在这些独立库之上:
| 子系统 | 职责 | 为什么需要独立库 |
|---|---|---|
| MarkdownView | 流式 Markdown 渲染 | Markdown 解析 + Litext 布局,约 8500 行(3.9.1) |
| 完整聊天 UI(内置,后开源为 LanguageModelChatUI) | 聊天界面主体 | 消息列表、流式动画、工具调用展示、语音输入 |
| ChatClientKit | OpenAI 兼容 API 客户端 | SSE 流式解析、请求管理、重试逻辑 |
| ColorfulX | 动画颜色/渐变渲染 | 打字机效果中的背景动画、品牌色渐变 |
| ListViewKit | 高性能列表视图 | diffable data source、cell 复用、动态高度缓存 |
| GlyphixTextFx | 文字特效 | 打字机逐字 reveal 效果 |
| AlertController | 弹窗系统 | 工具确认弹窗、MCP 授权弹窗 |
ChatClientKit 是两边共担的协议层成本——TUI 同样需要 SSE 解析和请求管理。真正拉开差距的是其余六个 UI 子系统。
对照 TUI:
| GUI 子系统 | TUI 对应 | 成本差异 |
|---|---|---|
| MarkdownView | StreamingMarkdownEngine(~800 行 Swift) | TUI 略省(~800 行 vs 8500 行,布局成本在渲染引擎里另算)——第 3 篇和第 7 篇各自展开 |
| ListViewKit | TranscriptBuffer(一个 [String] 数组) | TUI 零成本 |
| ColorfulX / GlyphixTextFx | 不需要——没有像素和动画 | GUI 独占 |
| AlertController | 一行 [y/n] 提示 | GUI 独占 |
| 完整聊天 UI(内置) | RenderLoop + commit/live 分区 | GUI 重得多 |
这就是为什么第 1 篇说 TUI 的复杂度天花板低。你不需要做动画渲染引擎,因为没有动画。不需要做 cell 复用,因为终端不回收行。不需要做 MCP 授权弹窗,因为终端只有键盘输入。
三、Agent 让 GUI 的复杂度再翻倍
普通聊天应用的状态:用户消息 → AI 回复。线性,两个方向。
Agent 应用的状态:用户消息 → thinking → 工具调用 × N(可能并行)→ 乱序返回 → thinking → 流式回复 + 中途取消 + 重试。第 4 篇和第 5 篇已经充分展示了这棵树的复杂程度。
每种状态在 GUI 上都是一个独立的 UI 子系统。
3.1 工具调用的四态状态机
一个工具调用在 GUI 上需要展示至少四个状态:
pending → 灰色图标 + 工具名称(等待执行)
running → 旋转动画 + 状态文本(执行中)
success → 绿色勾 + 结果摘要(可展开查看详情)
error → 红色叉 + 错误信息(可重试按钮)
而且多个工具调用可能并行。第 4 篇讲的 pendingTools 稳定槽位队列 + shiftIndices 索引追踪,在 GUI 上对应的是 UICollectionView 的多 section diffable snapshot——底层数据结构类似(事件 → 索引 → 局部更新),但 UI 表达成本不在同一量级:TUI 是替换一行文本,GUI 是独立的卡片、独立的加载动画、独立的结果展开/折叠、独立的错误重试按钮。
3.2 MCP 工具确认
MCP(Model Context Protocol)工具在执行前可能需要用户确认——“此工具将读取你的日历数据,是否允许?”
GUI 上这是一个弹窗或内联确认卡片,含工具描述、权限列表、允许/拒绝/总是允许三个按钮。TUI 上是一行 [y/n] 提示。后者可以很轻,前者是一个完整的弹窗子系统——AlertController 的代码量不亚于一个小型 UI 库。
3.3 上下文窗口可视化
Agent 的上下文窗口是有限资源。GUI 上可以展示 token 用量进度条、上下文溢出预警、历史消息压缩提示——UIProgressView + UILabel + 动画过渡,组合在一起就是一套完整的子系统。TUI 上你最多在状态栏展示 [context: 85%]。
机制上这个子系统有三层。数据层:token 用量从哪来——API 响应里的 usage 字段是滞后值(一次请求完成后才知道),流式期间的实时用量要靠本地 tokenizer 估算,两个数据源的口径并不一致,界面上要决定显示哪一个。状态层:用量是会话级的单调递增状态,但历史消息压缩会让它突然回落——进度条要处理这种非单调跳变,动画方向是反的。交互层:预警阈值触发后做什么——只是变红,还是给出“开启新会话 / 压缩历史“的操作入口——这是产品决策,不是 UI 决策。
3.4 多模态嵌入
GUI Agent 客户端需要支持图片附件(Vision 模型输入)、文件附件(代码文件、PDF)、语音输入(转写)。每种附件类型都需要自己的缩略图渲染、上传进度指示、错误重试、点击放大/预览。TUI 上多模态是文件路径字符串——file:///path/to/image.png。
每种附件背后是一条完整的管线:选取(系统 picker 或拖拽)→ 本地校验(格式、大小)→ 缩略图生成(后台线程,主线程不能卡)→ 上传(进度回调、失败重试)→ 发送后预览(点击放大、长按保存)。管线的每一环都是一个状态机,而它们共享同一个 cell——附件还在上传途中用户就点了发送,“文本已发、附件未完成“这个中间态怎么展示,是没有现成答案的问题。TUI 绕开了整条管线:路径字符串不需要状态机。
四、长会话的性能
第 2 篇讲过 TUI 在长会话下的困境——帧超过终端高度触发全量重绘,Live Budget Planner 把超预算的 live 行沉降到 committed 区,物理行缓存全部重算。
GUI 在长会话下的性能问题是不同性质的。UITableView / UICollectionView 天生支持 cell 复用:屏幕外 cell 被回收,屏幕内 cell 被复用填充新数据。这是 iOS SDK 内建能力,不需要手写。
但这不意味 GUI 没有性能挑战。万级消息的列表仍然需要:
- 分页加载(滚到顶部时异步加载更早的消息)
- 动态高度缓存(避免每次
heightForRowAt都重新 layout cell——iOS 8+ 的 self-sizing cell 能自动算高,但对流式更新、高度异构的富文本 cell,自动计算意味着反复 layout,手写缓存仍有必要) - 图片/文件附件的缩略图缓存(NSCache + 磁盘 LRU)
TUI 和 GUI 在长会话性能上的关系是“难的方向不同“。TUI 难在物理行计算和增量 diff;GUI 难在内存管理和 cell 复用策略。
五、Dark Mode 和无障碍——TUI 相对省事,GUI 要逐项手写
这是 TUI 相对省事的能力,但不是免费。终端设置的颜色方案通常会自动带到 ANSI 渲染上;无障碍支持取决于终端和具体实现。
GUI 上这两个是实打实的工作量——但要分开说。Dark Mode 对系统颜色免费,对自定义颜色不免费:每个自定义颜色都要定义 light/dark variant,每个自定义 view 都要在 traitCollectionDidChange 里刷新。还有一个隐蔽成本是动态色解析——UIColor.systemBlue 这类动态颜色在 trait 变化时重新解析,但自定义绘制(draw(_:)、CALayer)里的颜色不会自动跟随,需要手动监听并重绘;渐变、阴影这类叠加效果在两种模式下的对比度要分别调。
无障碍同样分两半:标准控件(UIButton、UILabel)由系统免费获得 VoiceOver 支持;自定义绘制的 view 则全是手写工作量——为每个交互元素设置 accessibilityLabel / accessibilityHint,为自定义容器实现 UIAccessibilityContainer,为图表类内容提供文字化描述。而 Agent 客户端恰好多是自定义绘制:tool card、diff 视图、流式动画。不难,但多——每个自定义 view 都要做,漏一个就是 bug。
六、跨平台:从零成本到选阵营
第 2 篇讲过 TUI 的跨平台——同一套 ANSI 序列,所有终端通用。
GUI 没有这个选项。选了 UIKit → 只有 Apple 平台。选了 Electron → 全平台但有 150MB+ 基线。选了 Flutter → 全平台但 Dart 生态有限。选了 Compose Multiplatform → iOS 已稳定,但生态和三方库覆盖仍比 UIKit / SwiftUI 薄。
对于独立开发者或小团队,这个选择直接决定能做多大。FlowDown 选择 UIKit——所以当前实现只覆盖 Apple 平台。覆盖 Android 和 Web 基本等于重写整个 UI 层。
七、回到 Event Sourcing:同一套事件,翻倍的投影成本
第 5 篇的核心论点是:CLI/TUI/GUI 都是同一事件流的不同投影。第 2–4 篇展示了 TUI 的投影成本。这一篇展示的是同一个东西在 GUI 上的投影成本。
同样一个 tool.started 事件:TUI 里是一行文本,GUI 里是一个带旋转动画的卡片。同样是 file.changed:TUI 里是路径字符串,GUI 里是 diff 审查面板。同样是 permission.requested:TUI 里是 [y/n],GUI 里是完整弹窗。
投影成本不消失,只是换了形态。TUI 的成本集中在渲染管线的精确性(物理行、帧状态、增量 diff),GUI 的成本分散在子系统数量和交互细节。 哪个更难?取决于你的团队结构和目标平台。但有一件事是确定的——做一个“看起来还不错“的 Agent GUI,起点就是七个子系统。
八、从这里出发
这一篇回答了一个看似矛盾的问题:既然 TUI 有那么多结构性优势(第 1、2 篇),为什么还有人做 GUI?因为 GUI 的结构性优势同样真实——像素级渲染让历史回看不被终端行数限制、多面板比较天然成立、语法高亮和视觉化 diff 不需要手写 tokenizer、无障碍对标准控件由系统免费获得。
不是谁更好。选 TUI 是因为你必须在受限环境中交付——ssh、低配机器、管道化、CI 集成。选 GUI 是因为你的用户期望“不错“——而“不错“在 GUI 上的门槛,比很多人想象的高得多。
下一篇聚焦这个门槛里最核心的一块:GUI 上的 Markdown 流式渲染——打字机效果背后的三层管线(解析 → 构建 → 布局),以及为什么全量重建才是瓶颈。
本文的 GUI 子系统拆解基于 FlowDown 的实际架构分析。TUI 对照部分基于 ForgeLoopTUI v1.2.0。