Agent 时代的开发者界面(四):Agent TUI——当事件树撞上线性终端
系列第四篇,TUI 三部曲的最后一篇。第 2 篇建立了渲染引擎的基础,第 3 篇拆解了 Markdown 在终端里的流式难题。这一篇聚焦 Agent 场景独有的一组问题——当流式输出、工具调用、thinking 展开、用户输入四件事同时在终端里发生时,渲染系统面对的不再是“画得好看“,而是“能不能不崩“。
一、回到那个直觉:“1D → 1D”
第 1 篇提过一个观点:有人说 LLM 输出是 1D 的线性 token 流,CLI 也是 1D 的,所以天然匹配。
现在可以完整回应这句话了。
对于最简单的“用户问 → AI 答“,确实如此。但 Agent 的真实输出结构是这样的:
用户消息
├── thinking(模型推理过程,可能很长)
├── 工具调用 1(开始 → 运行中 → 完成/失败)
│ └── 工具结果回传
├── 工具调用 2(可能与工具 1 并行,且可能比工具 1 先返回)
├── thinking(基于工具结果继续推理)
└── 最终回复(流式输出)
这不是一条线,是一棵树。而且节点到达顺序不是拓扑序。工具调用 2 可能比工具调用 1 先返回,thinking 块可能在流式输出的中途插入,用户随时按取消键。
CLI 看起来“适配“,不是因为 Agent 的输出真的是 1D 的——而是 TUI 渲染引擎在背后做了一套复杂的展平机制,把一棵乱序到达的树强行投影成线性输出。 这套机制就是本文要拆解的东西。
二、CoreRenderEvent:去掉会话角色语义的事件系统
ForgeLoopTUI 设计了一套不绑定业务模型的事件词汇:
public enum CoreRenderEvent {
case insert(lines: [String]) // 静态文本
case blockStart(id: String) // 开始流式块
case blockUpdate(id: String, lines: [String]) // 更新流式块
case blockEnd(id: String, lines: [String], footer: String?) // 结束流式块
case blockCancel(id: String) // 用户取消
case thinking(content: String, isFinal: Bool) // 推理过程
case operationStart(id: String, header: String, status: String) // 工具开始
case operationEnd(id: String, isError: Bool, result: String?) // 工具结束
case notification(text: String) // 通知
}
关键设计决策:不区分 user/assistant/tool 的会话角色语义,但保留执行结构语义。 thinking、operationStart/End 这些事件类型本身就是 Agent 的执行语义——去掉的是“这句话是谁说的“,留下的是“执行进行到哪一步“。chat 语义由上层 Adapter 注入,Core 层只知道“有一段内容要插入““有一个块在流式更新”“有一个操作开始了”。这让渲染系统可以复用到任何场景——AI 聊天、构建日志、测试报告——不需要改动渲染逻辑。
代价是渲染器不知道事件之间的业务关系。它不知道 operationEnd("tool_1") 对应哪个 operationStart,不知道 thinking 结束后会不会有流式输出。这些关系由 TranscriptRenderer 内部维护,通过一套索引追踪系统。
三、工具调用的乱序渲染
这是 Agent TUI 中最棘手的渲染问题。
3.1 问题
Agent 发起两个工具调用:
时间线:
t1: operationStart(id: "A", header: "● read_file(main.rs)")
t2: operationStart(id: "B", header: "● search_code(regex)")
t3: operationEnd(id: "B", result: "3 matches") ← B 先返回
t4: operationEnd(id: "A", result: "fn main() { ... }") ← A 后返回
如果按到达顺序渲染,输出是:
● read_file(main.rs)
⎿ running...
● search_code(regex)
⎿ running...
⎿ done: 3 matches ← 应该替换 B 的 running 行,却被追加到了帧尾
⎿ done: fn main()... ← A 的结果同样追加在最后
B 的 done 行本应替换掉 B 自己的 ⎿ running...,但按到达顺序渲染只会把结果追加到帧尾——B 的 running 行残留成僵尸状态,结果行却落到了离自己 header 两行开外的位置。
3.2 稳定槽位队列
ForgeLoopTUI 的解法是 pendingTools 数组——按 operationStart 到达顺序维护槽位:
case .operationStart(let id, let header, let status):
append(header) // ● read_file(main.rs)
append(status) // ⎿ running...
pendingTools.append((id: id, lineIndex: lines.count - 1))
// lineIndex 指向 status 行的位置
operationStart 时记录了每个工具的状态行在 Transcript 中的绝对行号。当 operationEnd 到来时:
case .operationEnd(let id, let isError, let result):
guard let slotIndex = pendingTools.firstIndex(where: { $0.id == id }) else { break }
let lineIndex = pendingTools[slotIndex].lineIndex
pendingTools.remove(at: slotIndex)
let resultLines = formatToolResult(result)
// 用结果行替换原来的 "⎿ running..." 行
lines.replace(range: lineIndex..<(lineIndex + 1), with: resultLines)
关键:不关心事件到达顺序,只关心槽位。 每个工具在第一次 operationStart 时就分配了一个固定位置(lineIndex)。后续的 operationEnd 直接定位到那个位置替换状态行,不管中间插入了多少其他工具。(顺带说明:lines 不是裸数组,是带边界钳制封装的 TranscriptBuffer,replace(range:with:) 内部钳制后走标准 replaceSubrange。)
但有一个连锁问题:如果 B 先结束,B 的结果文本比 ⎿ running... 长(3 行 vs 1 行),在 B 的位置插入 3 行会把后续所有行的索引推偏 2 行。这时需要 shiftIndices 把所有受影响的引用全部偏移:
private func shiftIndices(after threshold: Int, by delta: Int) {
for index in pendingTools.indices {
if pendingTools[index].lineIndex > threshold {
pendingTools[index].lineIndex += delta
}
}
// 同理偏移 notificationLines、streamingRange、completedRange、thinkingRange
}
不做这个偏移,下一个 operationEnd 的 lineIndex 就指向了错误的行,结果写到别人的位置。
补记(2026.08):本节描述的是全屏自管派的乱序处理——在扁平行数组上做坐标手术。inline viewport 派把乱序配对提前到了数据层:工具调用按稳定 id 在内存模型里配对、完成后才“发射“进终端 scrollback,乱序问题从坐标手术退化为字典查找。详见第 11 篇。
这里要和第 2 篇的一个结论对齐:commit/live 分区模型里,committed 区“只追加,不改写“。那上面这些 lines.replace 原地替换——以及后面 thinking 的原地更新、通知的删除——不矛盾吗?不矛盾,关键在时序:这些操作针对的行都还处于 live 阶段,尚未被 Live Budget Planner 沉降到 committed 区。一行内容在 commit 之前是可变的——可以被替换、删除、连带偏移;一旦沉降完成,它进入只追加区域,后续任何事件都不会再改写它。“不可变“是 commit 之后的性质,不是写入即生效的性质。
四、thinking 块的渲染
thinking(推理过程)和普通流式输出的区别在于:它是模型在“想“,不是对用户的回复。视觉上需要区分。
case .thinking(let content, let isFinal):
let thinkingLines = content.isEmpty
? []
: content.split(separator: "\n").map { "💭 \($0)" }
每行加 💭 前缀。thinking 块维护自己的 thinkingRange,每次更新是原地替换:
if let range = thinkingRange {
let delta = thinkingLines.count - range.count
lines.replace(range: range, with: thinkingLines)
if delta != 0 {
shiftIndices(after: range.upperBound - 1, by: delta)
}
thinkingRange = range.lowerBound..<(range.lowerBound + thinkingLines.count)
} else {
let start = lines.count
lines.append(contentsOf: thinkingLines)
thinkingRange = start..<(start + thinkingLines.count)
}
首次 thinking 事件创建新范围;后续事件在同一个范围原地替换——因为 thinking 内容是累积更新的,每次收到的都是完整文本。注意替换会改变行数:新旧行数差 delta 不为零时,必须像「三」「五」那样偏移后续索引,否则排在 thinking 之后的工具槽位和流式范围会全部错位。
isFinal 为 true 时清除 thinkingRange,后续的流式回复或工具调用正常追加在后面。
五、通知的 FIFO 队列(有上限)
Agent 在运行过程中会产生系统通知——“工具调用超时”“网络重试第 3 次”“上下文窗口使用率 85%”。这些通知需要展示但不能淹没主要对话内容。
case .notification(let text):
appendNotification("▸ \(text)")
appendNotification 维护一个有上限的 FIFO 索引队列:
private var notificationLines: [Int] = [] // 存行号,不存内容
private func appendNotification(_ line: String) {
lines.append(line)
notificationLines.append(lines.count - 1)
while notificationLines.count > options.maxNotificationLines { // 默认 3
let oldIndex = notificationLines.removeFirst()
lines.replace(range: oldIndex..<(oldIndex + 1), with: [])
shiftIndices(after: oldIndex - 1, by: -1)
}
}
超过上限(默认 3 条)时,最旧的通知行从 Transcript 中删除,后续所有索引偏移 -1。不是折叠——是直接删除,因为通知是瞬态的,过时即无用。
六、blockCancel:用户按下取消键
Agent 正在流式输出一段很长的代码,用户按了 Esc 取消。这时不能只是停止发送新内容——已经显示在屏幕上的流式内容需要被清除,换成 [cancelled] 标记。
case .blockCancel:
replaceStreaming(with: ["[cancelled]"])
streamingRange = nil
completedRange = nil
markdownEngine.reset()
append("")
replaceStreaming 是统一的“替换当前流式区域“方法:
private func replaceStreaming(with newLines: [String]) {
let range = streamingRange ?? (lines.count..<lines.count)
lines.replace(range: range, with: newLines)
streamingRange = range.lowerBound..<(range.lowerBound + newLines.count)
}
如果 streamingRange 为 nil(没有活跃的流式块),退化为 append。取消后清空 streamingRange 和 completedRange,Markdown 引擎重置——Agent 可以立即开始下一个请求。
七、工具结果的截断
工具返回值可能很长——read_file 可能返回几百行代码,search_code 可能返回几十条匹配。在终端里全量展示会淹没对话流。
private func formatToolResult(_ text: String?) -> [String] {
guard let text, !text.isEmpty else { return [] }
let allLines = text.split(separator: "\n").map(String.init)
var previewLines = Array(allLines.prefix(options.maxSummaryLines)) // 默认 3 行
if allLines.count > options.maxSummaryLines {
previewLines.append("...")
}
return previewLines.map { line in
if line.count > options.maxSummaryChars { // 默认 120 字符
return String(line.prefix(options.maxSummaryChars)) + "..."
}
return line
}
}
每行限制 120 字符 + 总共最多 3 行。在当前 commit/live 分区模型下,折叠不是一个简单的 show/hide 开关——一旦要让历史区域可折叠,就必须重新定义已提交内容的可见性和重绘策略。当前选择了最直接的方案:截断,用 ... 标记。
八、多行输入框与帧合成
第 2 篇详细讲了 commit/live 分区模型和 cursorPlacement 机制。Agent 场景下有一个特殊问题:多行输入。
普通聊天 TUI 只需要单行输入框。Agent 场景中,用户经常需要粘贴多行代码、编辑长 prompt、在输入框里直接换行。
ForgeLoopTUI 的 MultiLineInputState 支持多行文本输入,光标锚定在输入框的当前编辑位置。这里有一个微妙的竞态:Agent 正在流式输出,用户同时在多行输入框里打字。两件事都在触发渲染。上游把不同来源的状态合成帧之后,第 2 篇讲的 RenderLoop 帧合并负责把这帧稳定刷出去,避免终端在“追加流式内容“和“更新输入框光标“之间出现 ANSI 序列交错。
九、Agent 的不确定性如何放大一切
前两篇讲的 TUI 问题——物理行计算、增量 diff 的脆弱性、Markdown 的流式不确定性——在普通聊天场景下已经是难题。Agent 场景让每个问题都严重了一个量级。
内容长度不可预测。 普通聊天的回复可能有几十到几百个 token。Agent 的工具调用结果可能是一整个文件的数千行代码。第 2 篇讲的 Live Budget Planner 在 Agent 场景下不是“优化“而是“必需的防御“——没有 budget 限制,一次工具返回就能把帧推到几百物理行,全量重绘 + 终端滚动缓冲区溢出。
事件类型不可预测。 你无法预知下一个事件是 thinking、tool call、streaming text、还是 error。渲染器必须正确响应所有事件组合——包括之前没见过的组合。比如某些模型的 thinking 块可以在流式回复的中间穿插出现,这不是规范行为,但实际发生了。
中断时机不可预测。 用户可能在流式输出的任何时刻取消。取消时 Markdown 引擎可能处于任何状态——代码块中间、表格中间、列表中间。blockCancel 必须无条件 reset 引擎、清空 streaming 状态、写取消标记——不能假定 Markdown 结构是完整的。
这三个“不可预测“叠加在一起,意味着 Agent TUI 的渲染系统不能对事件序列做任何结构性假设。每个事件到达时,只根据当前 Transcript 的索引状态做局部替换,不依赖“前一个事件是什么“,不假设“下一个事件什么时候到“。
这不是“无状态“。恰恰相反,渲染器的全部正确性都系于一份共享的可变状态:lines(Transcript 的内容数组)和几个 Range(streaming/thinking/completed 的位置追踪)。准确的说法是事件处理无会话假设——这也是为什么 applyCore 方法是一个巨大的 switch 语句:每个 case 独立处理一个事件类型,不依赖前序事件的种类,只依赖那份共享索引状态。
十、TUI 三部曲的终点
三篇文章覆盖了从底层渲染到上层 Agent 场景的完整技术栈:
第2篇: ANSI 序列 + 字符网格 + 增量 diff → 渲染引擎层
第3篇: 降级映射 + Stable Prefix Cache + 流式检测 → Markdown 层
第4篇: 事件乱序 + 工具渲染 + 状态管理 → Agent 层
这三层之间有一个递进关系:每一层都建立在下一层之上,每一层的问题都因为底层的限制而更难解决。Agent 的树状事件在终端里变成线性输出——这个“树展平“的过程,就是 TUI 渲染引擎存在的全部意义。
但展平只是手段。真正的问题是:凭什么树状事件一定要被展平成线性?如果终端不是唯一的投影介质呢?
下一篇是系列的中场——Event Sourcing,Agent UI 的底层模型。它解释为什么 CLI/TUI 的挣扎不是终端的问题,而是“树状事件 → 线性输出“这个投影方式本身的结构性矛盾。一旦你把 Agent 的执行抽象为事件流,CLI/TUI/GUI/IDE/Web/Mobile 各自应该站在哪里,就不再是形态之争了。
本文所有技术细节基于 ForgeLoopTUI v1.2.0 的实际源码。其中 thinking 替换的 shiftIndices 调用为 v1.2.1 的索引偏移修复,v1.2.0 及更早版本中不存在。